cc-session-control 0.7.3__tar.gz → 0.7.5__tar.gz
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.
- {cc_session_control-0.7.3/src/cc_session_control.egg-info → cc_session_control-0.7.5}/PKG-INFO +24 -4
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/README.md +16 -3
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/pyproject.toml +41 -1
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/__init__.py +1 -1
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/__main__.py +1 -1
- cc_session_control-0.7.5/src/cc_session_control/actions/agent_ops.py +351 -0
- cc_session_control-0.7.5/src/cc_session_control/actions/feedback.py +77 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/actions/resume_list.py +52 -14
- cc_session_control-0.7.5/src/cc_session_control/actions/runner.py +133 -0
- cc_session_control-0.7.5/src/cc_session_control/actions/session_ops.py +574 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/actions/skill_ops.py +3 -1
- cc_session_control-0.7.5/src/cc_session_control/actions/tui_actions.py +383 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/app.py +182 -60
- cc_session_control-0.7.5/src/cc_session_control/cli.py +229 -0
- cc_session_control-0.7.5/src/cc_session_control/cli_commands.py +546 -0
- cc_session_control-0.7.5/src/cc_session_control/cli_rc.py +335 -0
- cc_session_control-0.7.5/src/cc_session_control/cli_streams.py +25 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/clipboard.py +6 -3
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/config.py +29 -2
- cc_session_control-0.7.5/src/cc_session_control/data/age_cleanup.py +185 -0
- cc_session_control-0.7.5/src/cc_session_control/data/cleanup.py +491 -0
- cc_session_control-0.7.5/src/cc_session_control/data/cleanup_anchors.py +119 -0
- cc_session_control-0.7.5/src/cc_session_control/data/cleanup_liveness.py +34 -0
- cc_session_control-0.7.5/src/cc_session_control/data/cleanup_selection.py +65 -0
- cc_session_control-0.7.5/src/cc_session_control/data/environment_ledger.py +483 -0
- cc_session_control-0.7.5/src/cc_session_control/data/environments.py +379 -0
- cc_session_control-0.7.5/src/cc_session_control/data/liveness.py +467 -0
- cc_session_control-0.7.5/src/cc_session_control/data/proc.py +416 -0
- cc_session_control-0.7.5/src/cc_session_control/data/project_settings.py +386 -0
- cc_session_control-0.7.5/src/cc_session_control/data/rc.py +493 -0
- cc_session_control-0.7.5/src/cc_session_control/data/rc_enabled.py +454 -0
- cc_session_control-0.7.5/src/cc_session_control/data/rc_environment.py +181 -0
- cc_session_control-0.7.5/src/cc_session_control/data/rc_outcomes.py +248 -0
- cc_session_control-0.7.5/src/cc_session_control/data/refresh.py +263 -0
- cc_session_control-0.7.5/src/cc_session_control/data/registry.py +297 -0
- cc_session_control-0.7.5/src/cc_session_control/data/removal.py +600 -0
- cc_session_control-0.7.5/src/cc_session_control/data/sessions.py +225 -0
- cc_session_control-0.7.5/src/cc_session_control/data/snapshot.py +147 -0
- cc_session_control-0.7.5/src/cc_session_control/data/tmux.py +544 -0
- cc_session_control-0.7.5/src/cc_session_control/data/tmux_outcomes.py +241 -0
- cc_session_control-0.7.5/src/cc_session_control/data/transcripts.py +254 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/models.py +151 -46
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/skill/SKILL.md +17 -5
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/theme.py +86 -43
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/_base.py +23 -6
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/_colspec.py +5 -2
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/_confirm.py +45 -15
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/_keytable.py +5 -5
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/_rows.py +27 -10
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/_session_row.py +43 -23
- cc_session_control-0.7.5/src/cc_session_control/views/_sessions_cleanup.py +336 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/agents.py +170 -107
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/rc.py +240 -109
- cc_session_control-0.7.5/src/cc_session_control/views/sessions.py +541 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5/src/cc_session_control.egg-info}/PKG-INFO +24 -4
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control.egg-info/SOURCES.txt +48 -1
- cc_session_control-0.7.5/src/cc_session_control.egg-info/requires.txt +9 -0
- cc_session_control-0.7.5/tests/test_action_runner.py +143 -0
- cc_session_control-0.7.5/tests/test_agent_ops.py +583 -0
- cc_session_control-0.7.5/tests/test_agent_takeover.py +355 -0
- cc_session_control-0.7.5/tests/test_agents.py +552 -0
- cc_session_control-0.7.5/tests/test_agents_async.py +153 -0
- cc_session_control-0.7.5/tests/test_app.py +739 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/tests/test_cleanup.py +243 -60
- cc_session_control-0.7.5/tests/test_cleanup_anchors.py +608 -0
- cc_session_control-0.7.5/tests/test_cleanup_fresh_evidence.py +470 -0
- cc_session_control-0.7.5/tests/test_cleanup_removal.py +554 -0
- cc_session_control-0.7.5/tests/test_cli.py +330 -0
- cc_session_control-0.7.5/tests/test_cli_entry.py +651 -0
- cc_session_control-0.7.5/tests/test_cli_entry_rc.py +472 -0
- cc_session_control-0.7.5/tests/test_cli_prune.py +702 -0
- cc_session_control-0.7.5/tests/test_cli_rc.py +625 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/tests/test_data.py +498 -201
- cc_session_control-0.7.5/tests/test_docs.py +121 -0
- cc_session_control-0.7.5/tests/test_environment_ledger.py +352 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/tests/test_environments.py +314 -41
- cc_session_control-0.7.5/tests/test_exit_intents.py +997 -0
- cc_session_control-0.7.5/tests/test_liveness.py +584 -0
- cc_session_control-0.7.5/tests/test_proc.py +276 -0
- cc_session_control-0.7.5/tests/test_project_settings.py +437 -0
- cc_session_control-0.7.5/tests/test_quality_scripts.py +138 -0
- cc_session_control-0.7.5/tests/test_rc.py +910 -0
- cc_session_control-0.7.5/tests/test_rc_enabled.py +581 -0
- cc_session_control-0.7.5/tests/test_rc_enabled_list_integration.py +139 -0
- cc_session_control-0.7.5/tests/test_rc_environment.py +553 -0
- cc_session_control-0.7.5/tests/test_rc_view_diagnostics.py +115 -0
- cc_session_control-0.7.5/tests/test_refresh.py +883 -0
- cc_session_control-0.7.5/tests/test_registry.py +316 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/tests/test_resume_list.py +46 -16
- cc_session_control-0.7.5/tests/test_sessions.py +519 -0
- cc_session_control-0.7.5/tests/test_sessions_async.py +334 -0
- cc_session_control-0.7.5/tests/test_sessions_tmux_residency_view.py +101 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/tests/test_skill_ops.py +1 -1
- cc_session_control-0.7.5/tests/test_smoke.py +135 -0
- cc_session_control-0.7.5/tests/test_snapshot.py +575 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/tests/test_theme.py +21 -8
- cc_session_control-0.7.5/tests/test_tmux.py +309 -0
- cc_session_control-0.7.5/tests/test_trust.py +390 -0
- cc_session_control-0.7.5/tests/test_tui_actions.py +793 -0
- cc_session_control-0.7.5/tests/test_validate_release_tag.py +120 -0
- cc_session_control-0.7.5/tests/test_views.py +793 -0
- cc_session_control-0.7.5/tests/test_views_cleanup.py +643 -0
- cc_session_control-0.7.5/tests/test_views_rc.py +690 -0
- cc_session_control-0.7.5/tests/test_workflows.py +191 -0
- cc_session_control-0.7.3/src/cc_session_control/actions/agent_ops.py +0 -179
- cc_session_control-0.7.3/src/cc_session_control/actions/session_ops.py +0 -281
- cc_session_control-0.7.3/src/cc_session_control/cli.py +0 -334
- cc_session_control-0.7.3/src/cc_session_control/data/cleanup.py +0 -496
- cc_session_control-0.7.3/src/cc_session_control/data/environments.py +0 -462
- cc_session_control-0.7.3/src/cc_session_control/data/liveness.py +0 -232
- cc_session_control-0.7.3/src/cc_session_control/data/proc.py +0 -233
- cc_session_control-0.7.3/src/cc_session_control/data/rc.py +0 -429
- cc_session_control-0.7.3/src/cc_session_control/data/registry.py +0 -149
- cc_session_control-0.7.3/src/cc_session_control/data/sessions.py +0 -222
- cc_session_control-0.7.3/src/cc_session_control/data/snapshot.py +0 -83
- cc_session_control-0.7.3/src/cc_session_control/data/tmux.py +0 -235
- cc_session_control-0.7.3/src/cc_session_control/views/_sessions_cleanup.py +0 -175
- cc_session_control-0.7.3/src/cc_session_control/views/sessions.py +0 -435
- cc_session_control-0.7.3/src/cc_session_control.egg-info/requires.txt +0 -4
- cc_session_control-0.7.3/tests/test_agent_ops.py +0 -245
- cc_session_control-0.7.3/tests/test_agents.py +0 -338
- cc_session_control-0.7.3/tests/test_app.py +0 -304
- cc_session_control-0.7.3/tests/test_cli.py +0 -112
- cc_session_control-0.7.3/tests/test_liveness.py +0 -236
- cc_session_control-0.7.3/tests/test_proc.py +0 -80
- cc_session_control-0.7.3/tests/test_rc.py +0 -254
- cc_session_control-0.7.3/tests/test_registry.py +0 -157
- cc_session_control-0.7.3/tests/test_sessions.py +0 -204
- cc_session_control-0.7.3/tests/test_smoke.py +0 -49
- cc_session_control-0.7.3/tests/test_snapshot.py +0 -94
- cc_session_control-0.7.3/tests/test_trust.py +0 -181
- cc_session_control-0.7.3/tests/test_views.py +0 -1285
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/LICENSE +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/setup.cfg +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/actions/__init__.py +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/data/__init__.py +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control/views/__init__.py +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control.egg-info/entry_points.txt +0 -0
- {cc_session_control-0.7.3 → cc_session_control-0.7.5}/src/cc_session_control.egg-info/top_level.txt +0 -0
{cc_session_control-0.7.3/src/cc_session_control.egg-info → cc_session_control-0.7.5}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cc-session-control
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
4
4
|
Summary: TUI manager for Claude Code sessions and Remote Control
|
|
5
5
|
Author: dzshzx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -12,18 +12,26 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Operating System :: POSIX :: Linux
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
17
|
Classifier: Topic :: Software Development :: User Interfaces
|
|
16
18
|
Requires-Python: >=3.12
|
|
17
19
|
Description-Content-Type: text/markdown
|
|
18
20
|
License-File: LICENSE
|
|
19
21
|
Requires-Dist: urwid>=2.0.0
|
|
20
22
|
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: coverage>=7.6; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=1.15; extra == "dev"
|
|
21
25
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=6.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pyyaml>=6.0; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.12; extra == "dev"
|
|
22
29
|
Dynamic: license-file
|
|
23
30
|
|
|
24
31
|
# cc-session-control
|
|
25
32
|
|
|
26
|
-
TUI
|
|
33
|
+
tmux-first TUI and headless CLI for [Claude Code](https://claude.ai/code)
|
|
34
|
+
sessions, background agents, and Remote Control.
|
|
27
35
|
|
|
28
36
|
**CLI command: `csctl`**
|
|
29
37
|
|
|
@@ -41,7 +49,8 @@ Built with [urwid](https://urwid.org/).
|
|
|
41
49
|
|
|
42
50
|
- Python 3.12+
|
|
43
51
|
- [Claude Code](https://claude.ai/code) CLI installed and authenticated
|
|
44
|
-
- tmux (
|
|
52
|
+
- tmux (the primary session-lifecycle carrier: launch, resume, background, and
|
|
53
|
+
survive terminal/SSH disconnects; managed Remote Control servers also use it)
|
|
45
54
|
- Linux / WSL (macOS support is partial — `/proc`-based liveness detection is Linux-only)
|
|
46
55
|
|
|
47
56
|
## Installation
|
|
@@ -89,6 +98,10 @@ csctl rc list # Show auto-start list
|
|
|
89
98
|
# Session cleanup
|
|
90
99
|
csctl prune # Dry run: show stats
|
|
91
100
|
csctl prune --max-prompts 1 --apply # Delete sessions with ≤1 prompt
|
|
101
|
+
csctl prune --sweep-orphans # Dry run: orphan sid-keyed artifact dirs
|
|
102
|
+
csctl prune --sweep-zombies # Dry run: dead sessions/<pid>.json files
|
|
103
|
+
csctl prune --sweep-aged # Dry run: age-keyed global entries
|
|
104
|
+
# Add --apply to exactly one of the sweep commands above to execute it.
|
|
92
105
|
|
|
93
106
|
# Resume rescue (headless): list sessions across directories with
|
|
94
107
|
# ready-to-copy resume commands (native /resume only searches the cwd
|
|
@@ -98,6 +111,10 @@ csctl resume mybug # Keyword: sid/cwd/title, then transcript body
|
|
|
98
111
|
csctl resume --page 2 # Next page
|
|
99
112
|
csctl resume --all # Everything, no paging
|
|
100
113
|
|
|
114
|
+
# Read-only inventory
|
|
115
|
+
csctl agents # Background agents: state, tempo, name, cwd
|
|
116
|
+
csctl env # Current + orphan bridge environments
|
|
117
|
+
|
|
101
118
|
# Bundled Claude Code skill (session-doctor knowledge for the agent)
|
|
102
119
|
csctl skill install # Write SKILL.md to ~/.claude/skills/
|
|
103
120
|
csctl skill install --force # Replace an existing skill directory
|
|
@@ -114,10 +131,13 @@ csctl --version
|
|
|
114
131
|
|---|---|---|
|
|
115
132
|
| `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
|
|
116
133
|
| `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
|
|
134
|
+
| `CSCTL_CLEANUP_AGE_DAYS` | `14` | Minimum age in days for `csctl prune --sweep-aged` (must be an integer ≥ 1) |
|
|
117
135
|
| `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
|
|
118
136
|
| `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
|
|
119
137
|
|
|
120
|
-
RC auto-start list is stored at
|
|
138
|
+
The RC auto-start list is stored at
|
|
139
|
+
`$XDG_CONFIG_HOME/csctl/rc-enabled`; the bridge-environment ledger is stored
|
|
140
|
+
at `$XDG_CONFIG_HOME/csctl/environments.jsonl`.
|
|
121
141
|
|
|
122
142
|
## License
|
|
123
143
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# cc-session-control
|
|
2
2
|
|
|
3
|
-
TUI
|
|
3
|
+
tmux-first TUI and headless CLI for [Claude Code](https://claude.ai/code)
|
|
4
|
+
sessions, background agents, and Remote Control.
|
|
4
5
|
|
|
5
6
|
**CLI command: `csctl`**
|
|
6
7
|
|
|
@@ -18,7 +19,8 @@ Built with [urwid](https://urwid.org/).
|
|
|
18
19
|
|
|
19
20
|
- Python 3.12+
|
|
20
21
|
- [Claude Code](https://claude.ai/code) CLI installed and authenticated
|
|
21
|
-
- tmux (
|
|
22
|
+
- tmux (the primary session-lifecycle carrier: launch, resume, background, and
|
|
23
|
+
survive terminal/SSH disconnects; managed Remote Control servers also use it)
|
|
22
24
|
- Linux / WSL (macOS support is partial — `/proc`-based liveness detection is Linux-only)
|
|
23
25
|
|
|
24
26
|
## Installation
|
|
@@ -66,6 +68,10 @@ csctl rc list # Show auto-start list
|
|
|
66
68
|
# Session cleanup
|
|
67
69
|
csctl prune # Dry run: show stats
|
|
68
70
|
csctl prune --max-prompts 1 --apply # Delete sessions with ≤1 prompt
|
|
71
|
+
csctl prune --sweep-orphans # Dry run: orphan sid-keyed artifact dirs
|
|
72
|
+
csctl prune --sweep-zombies # Dry run: dead sessions/<pid>.json files
|
|
73
|
+
csctl prune --sweep-aged # Dry run: age-keyed global entries
|
|
74
|
+
# Add --apply to exactly one of the sweep commands above to execute it.
|
|
69
75
|
|
|
70
76
|
# Resume rescue (headless): list sessions across directories with
|
|
71
77
|
# ready-to-copy resume commands (native /resume only searches the cwd
|
|
@@ -75,6 +81,10 @@ csctl resume mybug # Keyword: sid/cwd/title, then transcript body
|
|
|
75
81
|
csctl resume --page 2 # Next page
|
|
76
82
|
csctl resume --all # Everything, no paging
|
|
77
83
|
|
|
84
|
+
# Read-only inventory
|
|
85
|
+
csctl agents # Background agents: state, tempo, name, cwd
|
|
86
|
+
csctl env # Current + orphan bridge environments
|
|
87
|
+
|
|
78
88
|
# Bundled Claude Code skill (session-doctor knowledge for the agent)
|
|
79
89
|
csctl skill install # Write SKILL.md to ~/.claude/skills/
|
|
80
90
|
csctl skill install --force # Replace an existing skill directory
|
|
@@ -91,10 +101,13 @@ csctl --version
|
|
|
91
101
|
|---|---|---|
|
|
92
102
|
| `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
|
|
93
103
|
| `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
|
|
104
|
+
| `CSCTL_CLEANUP_AGE_DAYS` | `14` | Minimum age in days for `csctl prune --sweep-aged` (must be an integer ≥ 1) |
|
|
94
105
|
| `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
|
|
95
106
|
| `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
|
|
96
107
|
|
|
97
|
-
RC auto-start list is stored at
|
|
108
|
+
The RC auto-start list is stored at
|
|
109
|
+
`$XDG_CONFIG_HOME/csctl/rc-enabled`; the bridge-environment ledger is stored
|
|
110
|
+
at `$XDG_CONFIG_HOME/csctl/environments.jsonl`.
|
|
98
111
|
|
|
99
112
|
## License
|
|
100
113
|
|
|
@@ -17,6 +17,8 @@ classifiers = [
|
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Operating System :: POSIX :: Linux",
|
|
19
19
|
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
20
22
|
"Topic :: Software Development :: User Interfaces",
|
|
21
23
|
]
|
|
22
24
|
|
|
@@ -29,7 +31,14 @@ Issues = "https://github.com/dzshzx/cc-session-control/issues"
|
|
|
29
31
|
csctl = "cc_session_control.cli:main"
|
|
30
32
|
|
|
31
33
|
[project.optional-dependencies]
|
|
32
|
-
dev = [
|
|
34
|
+
dev = [
|
|
35
|
+
"coverage>=7.6",
|
|
36
|
+
"mypy>=1.15",
|
|
37
|
+
"pytest>=7.0",
|
|
38
|
+
"pytest-cov>=6.0",
|
|
39
|
+
"pyyaml>=6.0",
|
|
40
|
+
"ruff>=0.12",
|
|
41
|
+
]
|
|
33
42
|
|
|
34
43
|
[tool.setuptools.dynamic]
|
|
35
44
|
version = { attr = "cc_session_control.__version__" }
|
|
@@ -39,3 +48,34 @@ where = ["src"]
|
|
|
39
48
|
|
|
40
49
|
[tool.setuptools.package-data]
|
|
41
50
|
cc_session_control = ["skill/SKILL.md"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
target-version = "py312"
|
|
54
|
+
line-length = 88
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I", "UP", "B", "BLE"]
|
|
58
|
+
# Ruff's formatter deliberately leaves some long strings and comments intact,
|
|
59
|
+
# so format output, rather than E501, is the single line-wrapping authority.
|
|
60
|
+
ignore = ["E501"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
python_version = "3.12"
|
|
64
|
+
files = ["src"]
|
|
65
|
+
check_untyped_defs = true
|
|
66
|
+
warn_unused_configs = true
|
|
67
|
+
|
|
68
|
+
[[tool.mypy.overrides]]
|
|
69
|
+
module = ["urwid"]
|
|
70
|
+
# Keep the third-party widget boundary dynamic while checking every
|
|
71
|
+
# cc_session_control module and every project-owned type contract.
|
|
72
|
+
ignore_missing_imports = true
|
|
73
|
+
follow_imports = "skip"
|
|
74
|
+
|
|
75
|
+
[tool.coverage.run]
|
|
76
|
+
branch = true
|
|
77
|
+
relative_files = true
|
|
78
|
+
source = ["cc_session_control"]
|
|
79
|
+
|
|
80
|
+
[tool.coverage.report]
|
|
81
|
+
show_missing = true
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Background-agent lifecycle actions (R4 / Phase 6).
|
|
2
|
+
|
|
3
|
+
The persistent truth for a background agent lives in `jobs/<short>/state.json`
|
|
4
|
+
(registry.read_agent_jobs → AgentJob); it carries NO pid. So a live worker's host
|
|
5
|
+
pid is resolved by JOINing the job's sid back to `sessions/<pid>.json`
|
|
6
|
+
(the production takeover path does this inside one typed liveness snapshot;
|
|
7
|
+
`job_host` is records-only compatibility) — a live worker with no sessions file
|
|
8
|
+
is therefore unstoppable, a documented orphan risk surfaced in `HELP`.
|
|
9
|
+
|
|
10
|
+
Capability red lines honoured here:
|
|
11
|
+
- respawn/takeover never replace the csctl process (respawn spawns a tmux
|
|
12
|
+
window; takeover hands a Session to the existing `do_resume` path run AFTER
|
|
13
|
+
the UI loop exits).
|
|
14
|
+
- stop only signals a confirmed-live joined host pid; killing a
|
|
15
|
+
`--remote-control`/bg worker does not always fully reap it (orphan risk).
|
|
16
|
+
- destructive ops (remove/stop) refuse when "current" can't be determined
|
|
17
|
+
(no `/proc`, R10) so they never blind-hit csctl's own session.
|
|
18
|
+
|
|
19
|
+
This is an action module: internals are English, but the user-facing label/help
|
|
20
|
+
constants the (Phase 7) background view reads are Simplified Chinese.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shlex
|
|
26
|
+
from collections import deque
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from enum import Enum
|
|
29
|
+
|
|
30
|
+
from ..config import cfg
|
|
31
|
+
from ..data import cleanup, liveness, registry, tmux
|
|
32
|
+
from ..data import proc as proc
|
|
33
|
+
from ..data.removal import CleanupExecution
|
|
34
|
+
from ..models import AgentJob, Session
|
|
35
|
+
from . import session_ops
|
|
36
|
+
|
|
37
|
+
# --- host-pid join (resume-takeover compatibility view) -----------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def job_host(job: AgentJob, *, max_age: float = 5.0) -> tuple[int | None, bool]:
|
|
41
|
+
"""Resolve a background job's host pid + liveness — `(pid, alive)`.
|
|
42
|
+
|
|
43
|
+
`state.json` has no pid, so the worker's pid is JOINed from
|
|
44
|
+
`sessions/<pid>.json` on `job.sid` (a bg session proc; `kind` is typically
|
|
45
|
+
"bg"). Prefers a `/proc`-confirmed live match (so `alive=True` is trustworthy
|
|
46
|
+
and defeats pid reuse via `procStart`); falls back to the first sid match
|
|
47
|
+
with `alive=False`. Returns `(None, False)` when no sessions file exists for
|
|
48
|
+
the sid — that live worker is unstoppable (documented orphan risk).
|
|
49
|
+
|
|
50
|
+
Injects `/proc` liveness onto the registry rows, then defers to the single
|
|
51
|
+
pure join `registry.host_pid_for_sid` (shared with `snapshot._enrich_jobs`).
|
|
52
|
+
"""
|
|
53
|
+
procs = liveness.live_session_procs(max_age=max_age)
|
|
54
|
+
return registry.host_pid_for_sid(job.sid, procs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- respawn ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class RespawnResult:
|
|
62
|
+
"""Exact command plus the tmux target that proves it was launched."""
|
|
63
|
+
|
|
64
|
+
command: str
|
|
65
|
+
target: str | None
|
|
66
|
+
detail: str = ""
|
|
67
|
+
tmux_result: tmux.TmuxWriteResult | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def success(self) -> bool:
|
|
71
|
+
return self.target is not None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def respawn_cmd(job: AgentJob) -> str:
|
|
75
|
+
"""The exact relaunch command: `claude --resume <resume_sid> <flags> --bg`.
|
|
76
|
+
|
|
77
|
+
Pure string build via `shlex.join` (split from `respawn` so it can be copied
|
|
78
|
+
to the clipboard / asserted in tests). `respawn_flags` are reused verbatim
|
|
79
|
+
from the recorded job state.
|
|
80
|
+
"""
|
|
81
|
+
args = ["claude", "--resume", job.resume_sid, *job.respawn_flags, "--bg"]
|
|
82
|
+
return shlex.join(args)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _job_window(job: AgentJob) -> str:
|
|
86
|
+
"""tmux window name for a respawned agent (name or short, suffixed)."""
|
|
87
|
+
base = (job.name or "bg").strip() or "bg"
|
|
88
|
+
return f"{base}-{job.short[:8]}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def respawn_result(job: AgentJob) -> RespawnResult:
|
|
92
|
+
"""Relaunch a background agent while retaining the tmux outcome."""
|
|
93
|
+
cmd = respawn_cmd(job)
|
|
94
|
+
result = tmux.run_in_tmux_result(
|
|
95
|
+
tmux.session_name_for(job.cwd),
|
|
96
|
+
_job_window(job),
|
|
97
|
+
cmd,
|
|
98
|
+
)
|
|
99
|
+
target = result.target if result.success else None
|
|
100
|
+
return RespawnResult(cmd, target, result.diagnostic, result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- remove (settled agents only) ---------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def remove_job(job: AgentJob) -> CleanupExecution:
|
|
107
|
+
"""Remove a SETTLED background agent: `jobs/<short>/` + its sid artifacts.
|
|
108
|
+
|
|
109
|
+
The public data-layer executor owns the final fresh liveness check, so no
|
|
110
|
+
caller-held evidence can authorize deletion.
|
|
111
|
+
"""
|
|
112
|
+
result = CleanupExecution()
|
|
113
|
+
try:
|
|
114
|
+
anchors = cleanup.agent_removal_anchors(job.short, job.sid)
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
result.refuse([job.short], f"cannot establish removal anchor: {exc}")
|
|
117
|
+
return result
|
|
118
|
+
return cleanup.remove_agent_artifacts(job.short, job.sid, anchors=anchors)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- watch (read-only) --------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
_TIMELINE_LINE_LIMIT = 200
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TimelineReadState(Enum):
|
|
127
|
+
READY = "ready"
|
|
128
|
+
MISSING = "missing"
|
|
129
|
+
FAILED = "failed"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class TimelineReadResult:
|
|
134
|
+
"""Bounded timeline data for a main-loop read-only overlay."""
|
|
135
|
+
|
|
136
|
+
state: TimelineReadState
|
|
137
|
+
lines: tuple[str, ...] = ()
|
|
138
|
+
detail: str = ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def watch(job: AgentJob) -> TimelineReadResult:
|
|
142
|
+
"""Stream the last 200 timeline lines without loading the whole file."""
|
|
143
|
+
path = cfg.jobs_dir / job.short / "timeline.jsonl"
|
|
144
|
+
try:
|
|
145
|
+
with open(path, errors="ignore") as fh:
|
|
146
|
+
lines = deque(
|
|
147
|
+
(line.rstrip("\r\n") for line in fh),
|
|
148
|
+
maxlen=_TIMELINE_LINE_LIMIT,
|
|
149
|
+
)
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
return TimelineReadResult(TimelineReadState.MISSING)
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
return TimelineReadResult(TimelineReadState.FAILED, detail=str(exc))
|
|
154
|
+
return TimelineReadResult(TimelineReadState.READY, tuple(lines))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- resume takeover (reuses the existing foreground resume path) -------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TakeoverPreparationState(Enum):
|
|
161
|
+
"""Whether one fresh generation permits constructing a takeover session."""
|
|
162
|
+
|
|
163
|
+
READY = "ready"
|
|
164
|
+
REFUSED = "refused"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class TakeoverPreparationResult:
|
|
169
|
+
"""Typed result of preparing one background-agent takeover."""
|
|
170
|
+
|
|
171
|
+
state: TakeoverPreparationState
|
|
172
|
+
session: Session | None = None
|
|
173
|
+
detail: str = ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ready_takeover(
|
|
177
|
+
job: AgentJob,
|
|
178
|
+
*,
|
|
179
|
+
pid: int | None = None,
|
|
180
|
+
alive: bool = False,
|
|
181
|
+
current: bool = False,
|
|
182
|
+
proc_start: str = "",
|
|
183
|
+
tmux_target: str | None = None,
|
|
184
|
+
) -> TakeoverPreparationResult:
|
|
185
|
+
"""Build the existing resume-path adapter for one prepared agent."""
|
|
186
|
+
return TakeoverPreparationResult(
|
|
187
|
+
TakeoverPreparationState.READY,
|
|
188
|
+
session=Session(
|
|
189
|
+
sid=job.resume_sid,
|
|
190
|
+
cwd=job.cwd,
|
|
191
|
+
label=job.name or job.short,
|
|
192
|
+
mtime=0.0,
|
|
193
|
+
prompts=0,
|
|
194
|
+
pid=pid,
|
|
195
|
+
alive=alive,
|
|
196
|
+
current=current,
|
|
197
|
+
proc_start=proc_start,
|
|
198
|
+
source="bg",
|
|
199
|
+
agent_short=job.short,
|
|
200
|
+
tmux_target=tmux_target,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def prepare_takeover(job: AgentJob) -> TakeoverPreparationResult:
|
|
206
|
+
"""Prepare one background-agent resume without widening destructive gates.
|
|
207
|
+
|
|
208
|
+
Bringing a bg session to the foreground is just a resume of its
|
|
209
|
+
`resume_sid`, so a ready result carries the Session the view feeds to the SAME
|
|
210
|
+
`app.exit_with(ResumeIntent)` → `do_resume` pipeline used for foreground sessions —
|
|
211
|
+
all kill/exec/`_resume_plan` logic is reused, none duplicated (R4.4 takeover).
|
|
212
|
+
|
|
213
|
+
The published immutable ``job.host_alive`` decides whether this action can
|
|
214
|
+
kill. A dead job resumes directly without acquiring liveness or tmux state.
|
|
215
|
+
A live job acquires exactly one fresh typed generation; incomplete registry,
|
|
216
|
+
process, ancestor, or agents evidence is refused before any tmux lookup.
|
|
217
|
+
Host identity, liveness, proc-start, and current-session protection all come
|
|
218
|
+
from that same generation. If it proves the former host is gone, the action
|
|
219
|
+
safely contracts to the same non-destructive dead resume.
|
|
220
|
+
"""
|
|
221
|
+
if not job.host_alive:
|
|
222
|
+
return _ready_takeover(job)
|
|
223
|
+
|
|
224
|
+
evidence = liveness.liveness_inputs()
|
|
225
|
+
if not evidence.complete:
|
|
226
|
+
return TakeoverPreparationResult(
|
|
227
|
+
TakeoverPreparationState.REFUSED,
|
|
228
|
+
detail=_incomplete_liveness_detail(evidence),
|
|
229
|
+
)
|
|
230
|
+
pid, alive = registry.host_pid_for_sid(job.sid, evidence.session_procs)
|
|
231
|
+
if not alive or pid is None:
|
|
232
|
+
return _ready_takeover(job)
|
|
233
|
+
|
|
234
|
+
proc_start = next(
|
|
235
|
+
(
|
|
236
|
+
item.proc_start
|
|
237
|
+
for item in evidence.session_procs
|
|
238
|
+
if item.sid == job.sid and item.pid == pid and item.proc_alive is True
|
|
239
|
+
),
|
|
240
|
+
"",
|
|
241
|
+
)
|
|
242
|
+
residency = tmux.find_session_window_result([pid])
|
|
243
|
+
if not residency.complete:
|
|
244
|
+
detail = "; ".join(
|
|
245
|
+
f"{issue.source}"
|
|
246
|
+
+ (f" at {issue.path}" if issue.path else "")
|
|
247
|
+
+ f": {issue.detail}"
|
|
248
|
+
for issue in residency.issues
|
|
249
|
+
)
|
|
250
|
+
return TakeoverPreparationResult(
|
|
251
|
+
TakeoverPreparationState.REFUSED,
|
|
252
|
+
detail=detail,
|
|
253
|
+
)
|
|
254
|
+
return _ready_takeover(
|
|
255
|
+
job,
|
|
256
|
+
pid=pid,
|
|
257
|
+
alive=True,
|
|
258
|
+
current=pid in evidence.cur,
|
|
259
|
+
proc_start=proc_start,
|
|
260
|
+
tmux_target=residency.target,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --- stop (live workers only) -------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class AgentStopState(Enum):
|
|
268
|
+
"""Observable outcome of stopping one background agent."""
|
|
269
|
+
|
|
270
|
+
STOPPED = "stopped"
|
|
271
|
+
NOT_RUNNING = "not-running"
|
|
272
|
+
REFUSED = "refused"
|
|
273
|
+
FAILED = "failed"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass(frozen=True)
|
|
277
|
+
class AgentStopResult:
|
|
278
|
+
state: AgentStopState
|
|
279
|
+
pid: int | None = None
|
|
280
|
+
detail: str = ""
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def success(self) -> bool:
|
|
284
|
+
return self.state is AgentStopState.STOPPED
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _incomplete_liveness_detail(evidence: liveness.LivenessSnapshot) -> str:
|
|
288
|
+
issues = []
|
|
289
|
+
for issue in evidence.issues:
|
|
290
|
+
location = f" at {issue.path}" if issue.path else ""
|
|
291
|
+
issues.append(f"{issue.source}{location}: {issue.detail}")
|
|
292
|
+
return "liveness evidence incomplete: " + "; ".join(issues)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def stop_job_result(job: AgentJob) -> AgentStopResult:
|
|
296
|
+
"""Stop one live background agent from one fresh liveness snapshot.
|
|
297
|
+
|
|
298
|
+
The host pid is JOINed from `sessions/<pid>.json` (`job_host`); only a
|
|
299
|
+
confirmed-live pid is killed. Incomplete source evidence and unavailable
|
|
300
|
+
current-session determination refuse the destructive action. The same
|
|
301
|
+
snapshot supplies both the host pid and its proc-start identity, avoiding
|
|
302
|
+
a second compatibility scan before ``take_over_result`` rechecks liveness.
|
|
303
|
+
|
|
304
|
+
The kill itself is `session_ops.take_over_result` (the ONE primitive: R10 gate,
|
|
305
|
+
recheck, SIGTERM, cache invalidation). Its four outcomes map one-for-one to
|
|
306
|
+
this domain result.
|
|
307
|
+
|
|
308
|
+
Killing does not always fully reap a `--remote-control`/bg worker (orphan
|
|
309
|
+
risk, see `HELP`).
|
|
310
|
+
"""
|
|
311
|
+
evidence = liveness.liveness_inputs()
|
|
312
|
+
if not evidence.complete:
|
|
313
|
+
return AgentStopResult(
|
|
314
|
+
AgentStopState.REFUSED,
|
|
315
|
+
detail=_incomplete_liveness_detail(evidence),
|
|
316
|
+
)
|
|
317
|
+
pid, alive = registry.host_pid_for_sid(job.sid, evidence.session_procs)
|
|
318
|
+
if not alive or not pid:
|
|
319
|
+
return AgentStopResult(
|
|
320
|
+
AgentStopState.NOT_RUNNING,
|
|
321
|
+
pid=pid,
|
|
322
|
+
detail="no live host for background agent",
|
|
323
|
+
)
|
|
324
|
+
proc_start = next(
|
|
325
|
+
(
|
|
326
|
+
item.proc_start
|
|
327
|
+
for item in evidence.session_procs
|
|
328
|
+
if item.sid == job.sid and item.pid == pid
|
|
329
|
+
),
|
|
330
|
+
"",
|
|
331
|
+
)
|
|
332
|
+
outcome = session_ops.take_over_result(pid, proc_start)
|
|
333
|
+
if outcome.state is session_ops.TakeOverState.KILLED:
|
|
334
|
+
return AgentStopResult(AgentStopState.STOPPED, pid=pid)
|
|
335
|
+
if outcome.state is session_ops.TakeOverState.GONE:
|
|
336
|
+
return AgentStopResult(
|
|
337
|
+
AgentStopState.NOT_RUNNING,
|
|
338
|
+
pid=pid,
|
|
339
|
+
detail="background agent host is no longer running",
|
|
340
|
+
)
|
|
341
|
+
if outcome.state is session_ops.TakeOverState.REFUSED:
|
|
342
|
+
return AgentStopResult(
|
|
343
|
+
AgentStopState.REFUSED,
|
|
344
|
+
pid=pid,
|
|
345
|
+
detail=outcome.detail or "current session cannot be determined",
|
|
346
|
+
)
|
|
347
|
+
return AgentStopResult(
|
|
348
|
+
AgentStopState.FAILED,
|
|
349
|
+
pid=pid,
|
|
350
|
+
detail=outcome.detail or "failed to signal background agent host",
|
|
351
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Operator-facing summaries for typed cleanup execution results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..data.removal import CleanupExecution
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _protection_issue(result: CleanupExecution) -> str:
|
|
9
|
+
issue = result.issues[0]
|
|
10
|
+
where = f"({issue.path})" if issue.path else ""
|
|
11
|
+
return f"{issue.source}{where}:{issue.error}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _details(result: CleanupExecution) -> list[str]:
|
|
15
|
+
details: list[str] = []
|
|
16
|
+
if result.failed and result.removed:
|
|
17
|
+
details.append(f"已删除路径 {len(result.removed)}")
|
|
18
|
+
if result.failed:
|
|
19
|
+
first = result.failed[0]
|
|
20
|
+
detail = f"失败 {len(result.failed)}"
|
|
21
|
+
if first.error:
|
|
22
|
+
detail += f"({first.path}: {first.error})"
|
|
23
|
+
details.append(detail)
|
|
24
|
+
if result.skipped:
|
|
25
|
+
details.append(f"跳过 {len(result.skipped)}({result.skipped[0].reason})")
|
|
26
|
+
if result.refused:
|
|
27
|
+
details.append(f"拒绝 {len(result.refused)}({result.refused[0].reason})")
|
|
28
|
+
if result.missing_targets:
|
|
29
|
+
details.append(f"已不存在 {len(result.missing_targets)}")
|
|
30
|
+
return details
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_cleanup_notice(result: CleanupExecution, done_template: str) -> str:
|
|
34
|
+
"""Summarize multi-target cleanup without turning partial work into success."""
|
|
35
|
+
if result.issues and not result.removed:
|
|
36
|
+
return f"保护证据不完整,未删除:{_protection_issue(result)}"
|
|
37
|
+
completed = len(result.completed)
|
|
38
|
+
details = _details(result)
|
|
39
|
+
if completed and not details:
|
|
40
|
+
return done_template.format(n=completed)
|
|
41
|
+
if completed:
|
|
42
|
+
return f"部分完成:{done_template.format(n=completed)};" + ";".join(details)
|
|
43
|
+
if result.removed:
|
|
44
|
+
return "清理部分失败:" + ";".join(details)
|
|
45
|
+
if result.refused:
|
|
46
|
+
return "已拒绝清理:" + ";".join(details)
|
|
47
|
+
if result.failed:
|
|
48
|
+
return "清理失败:" + ";".join(details)
|
|
49
|
+
if details:
|
|
50
|
+
return "未清理任何项目:" + ";".join(details)
|
|
51
|
+
return "无可清理内容"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_delete_notice(result: CleanupExecution) -> str:
|
|
55
|
+
"""Summarize deletion of one session or background-agent artifact set."""
|
|
56
|
+
if result.issues and not result.removed:
|
|
57
|
+
return f"保护证据不完整,未删除:{_protection_issue(result)}"
|
|
58
|
+
if result.completed and not result.incomplete:
|
|
59
|
+
return "已删除"
|
|
60
|
+
if result.failed:
|
|
61
|
+
first = result.failed[0]
|
|
62
|
+
suffix = f":{first.error}" if first.error else ""
|
|
63
|
+
if result.removed:
|
|
64
|
+
return (
|
|
65
|
+
f"删除部分失败:已删除路径 {len(result.removed)};"
|
|
66
|
+
f"失败 {len(result.failed)}{suffix}"
|
|
67
|
+
)
|
|
68
|
+
return f"删除失败{suffix}"
|
|
69
|
+
if result.removed and result.incomplete:
|
|
70
|
+
return f"删除部分完成:已删除路径 {len(result.removed)};" + ";".join(
|
|
71
|
+
_details(result)
|
|
72
|
+
)
|
|
73
|
+
if result.refused:
|
|
74
|
+
return f"拒绝删除:{result.refused[0].reason}"
|
|
75
|
+
if result.skipped:
|
|
76
|
+
return f"未删除:{result.skipped[0].reason}"
|
|
77
|
+
return "无可删除内容"
|