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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. 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)