feat(sessions): prune pasted images no session still names

Refs #7221 (CW-7804). Pasted clipboard images are written as flat
clipboard-<nanos>.png files under ~/.codewhale/clipboard-images/ and
nothing ever removed them; the reporter's Windows install grew by
several hundred MB in a month of QQ screenshots.

`codewhale sessions prune-images` sits beside `scrub-secrets` and keeps
its shape: report by default, `--apply` to delete. An image is offered
only when no file under the Codewhale state directory, the sessions
directory or the Runtime task store names it (saved sessions,
checkpoints, archives, Runtime threads, the composer stash and history)
and it is older than the grace window (`--older-than-days`, default 7),
so an unsent draft or a running turn keeps its picture. The scan reads
raw bytes and needs no knowledge of each store's schema; it never
follows symlinks inside the roots and stops once every image is
accounted for. If any file or directory cannot be read, nothing is
deleted. A file another process holds open (common on Windows) is
reported and skipped. A missing image never fails resume: the bytes enter
history when a message is built (expand_attachment_blocks), and
re-sending an old message reports the missing file in-band.

The traversal reuses session_secret_scrub's no-follow directory helpers
(now pub(crate)). docs/READ_MEDIA.md documents the command and a
features.toml row registers it.

Known limits: references held only outside the scanned roots (for
example a workspace-local .codewhale directory) are not seen; age-only
pruning stays out of scope; grouping new pastes by session is not done.

Verified (macOS, offline, locked): the batch's six commits were tested as one change on Boat farm VMs
(Linux x86_64), shipping only `git diff <base> <final>`, so no other
session's uncommitted files took part. cargo test --locked --lib with
filters over config, runtime, engine, app-server, tui and cli on base
3631a174f6: 459 passed, 0 failed (config 8, runtime 117, engine 102,
tui 220, cli 8, app-server 4; farm run
20261107T230833Z-issues-tests-503e01). cargo clippy with the Lint job's
flags (--all-targets --all-features, -D warnings) on config, runtime,
engine, command-contract, cli, tui and app-server: 0 warnings, 0 errors
(run 20261107T231817Z-issues-clippy-e84152). Rebased onto 5ba1aa63bc
(eager sub-agents), which shares subagent/mod.rs, subagent/tests.rs,
tasks.rs and config.example.toml with this batch; this batch's changed
lines are byte-identical on both bases, and a recheck of that overlap on the new base passed: engine 48 and app-server 2 passed, 0 failed (subagent coordination incl. the two-process case, human-wait, execution-guard and tools::tasks suites, server_task_config and server_task_runtime; farm run 20261108T000442Z-issues-recheck-67ab1a).
Locally: cargo fmt --all -- --check, both budget checks,
sync-changelog --check and the feature release-note receipts pass.
Covers the session_secret_scrub suite whose
no-follow directory helpers this reuses, and the build of the new
subcommand. The fixture run of `codewhale sessions prune-images` on a
macOS binary follows from the freeze build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-11-07 16:40:24 -08:00
1 parent 711196c671
commit c98ea3836e
9 files changed
+338 -4

No files matched your search

+7
View File
@@ -70,6 +70,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Added
- **Prune pasted images:** `codewhale sessions prune-images` lists pasted
clipboard images that no saved session, checkpoint, Runtime thread, draft or
history still names and that are older than 7 days (`--older-than-days`);
`--apply` deletes them. If any file it must scan cannot be read, it deletes
nothing. Run it while no Codewhale session is open. Thanks
[@Asteria1028](https://github.com/Asteria1028) for the report (Refs #7221).
- **Task wall clock pauses for answerable human waits (#5898):** a durable task run by the Runtime API server no longer spends its wall-time budget while a prompt on that run waits for an approval or user-input answer. `[tools] task_human_wait_cap_seconds` (default 1800, maximum 86400) bounds the total wait and fails an unanswered task with `human_wait_timeout`, releasing its worker. Only a Runtime serving its HTTP API pauses the clock; local Stdio, Socket and ACP control frontends keep the wall deadline, and a wall deadline crossed before the prompt still ends the task. The TUI's own tasks are unchanged. Thanks [@asto18089](https://github.com/asto18089).
- **MCP servers say why they are not connected:** the Runtime API's
`GET /v1/apps/mcp/servers` now includes a configured server's last
@@ -613,6 +619,7 @@ until the matching tag, packages, checksums, and release assets exist.
### Contributors
- **[@Asteria1028](https://github.com/Asteria1028)** — reported pasted clipboard images piling up with no way to remove them ([#7221](https://github.com/codewhale-hq/Codewhale/issues/7221)).
- **[@zhuowp](https://github.com/zhuowp)** (git author `z27014`) — added bounded HTTP rate-limit recovery and corrected retry deadlines, quota guidance and recovery receipts ([#7220](https://github.com/codewhale-hq/Codewhale/pull/7220)).
- **[@printf033](https://github.com/printf033)** — enabled exclusive rename on Android so Fleet publication does not fall back to SELinux-denied hard links (`fbe9f24ee`).
- **[@liuyy2](https://github.com/liuyy2)** — diagnosed case-sensitive model selection collisions in the provider picker ([#7214](https://github.com/codewhale-hq/Codewhale/issues/7214)).
+1
View File
@@ -59,6 +59,7 @@ pub mod runtime_options;
pub mod runtime_threads;
pub mod scorecard;
pub mod session_export;
pub mod session_image_prune;
pub mod session_manager;
pub mod session_peek;
pub mod session_projection;
+179
View File
@@ -0,0 +1,179 @@
use std::collections::{BTreeMap, HashSet};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use crate::session_secret_scrub::{directory_entries, entry_has_type};
pub const PRUNE_IMAGES_COMMAND: &str = "codewhale sessions prune-images";
const NAME_PREFIX: &[u8] = b"clipboard-";
const NAME_SUFFIX: &[u8] = b".png";
const SCAN_CHUNK: usize = 1 << 20;
const NAME_CARRY: usize = 96;
#[derive(Debug, Default)]
pub struct ImagePruneReport {
pub images: usize,
pub image_bytes: u64,
pub referenced: usize,
pub recent: usize,
pub unreferenced: Vec<(PathBuf, u64)>,
pub removed: Vec<(PathBuf, u64)>,
pub failed: Vec<(PathBuf, String)>,
pub files_scanned: usize,
pub unreadable: Vec<PathBuf>,
}
impl ImagePruneReport {
#[must_use]
pub fn unreferenced_bytes(&self) -> u64 {
self.unreferenced.iter().map(|(_, bytes)| bytes).sum()
}
#[must_use]
pub fn removed_bytes(&self) -> u64 {
self.removed.iter().map(|(_, bytes)| bytes).sum()
}
}
#[must_use]
pub fn is_clipboard_image_name(name: &str) -> bool {
name.strip_prefix("clipboard-")
.and_then(|rest| rest.strip_suffix(".png"))
.is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
}
pub fn prune_clipboard_images(
image_dir: &Path,
scan_roots: &[PathBuf],
grace: Duration,
apply: bool,
) -> ImagePruneReport {
let mut report = ImagePruneReport::default();
let mut images: BTreeMap<String, (PathBuf, u64, SystemTime)> = BTreeMap::new();
for entry in directory_entries(image_dir, &mut report.unreadable) {
let name = entry.file_name().to_string_lossy().into_owned();
if !is_clipboard_image_name(&name) || !entry_has_type(&entry, false, &mut report.unreadable)
{
continue;
}
match entry.metadata() {
Ok(meta) => {
report.image_bytes += meta.len();
images.insert(
name,
(
entry.path(),
meta.len(),
meta.modified().unwrap_or(SystemTime::now()),
),
);
}
Err(_) => report.unreadable.push(entry.path()),
}
}
report.images = images.len();
if images.is_empty() {
return report;
}
let excluded = std::fs::canonicalize(image_dir).unwrap_or_else(|_| image_dir.to_path_buf());
let mut referenced = HashSet::new();
let mut seen_roots = HashSet::new();
let mut pending: Vec<PathBuf> = Vec::new();
for root in scan_roots {
if let Ok(root) = std::fs::canonicalize(root)
&& seen_roots.insert(root.clone())
{
pending.push(root);
}
}
let mut visited_dirs = HashSet::new();
while let Some(dir) = pending.pop() {
if dir == excluded || !visited_dirs.insert(dir.clone()) {
continue;
}
for entry in directory_entries(&dir, &mut report.unreadable) {
let Ok(kind) = entry.file_type() else {
report.unreadable.push(entry.path());
continue;
};
if kind.is_dir() {
pending.push(entry.path());
} else if kind.is_file() {
report.files_scanned += 1;
if scan_file(&entry.path(), &mut referenced).is_err() {
report.unreadable.push(entry.path());
}
if images
.keys()
.all(|name| referenced.contains(name.as_bytes()))
{
pending.clear();
break;
}
}
}
}
let now = SystemTime::now();
for (name, (path, bytes, modified)) in images {
if referenced.contains(name.as_bytes()) {
report.referenced += 1;
} else if now.duration_since(modified).unwrap_or_default() < grace {
report.recent += 1;
} else {
report.unreferenced.push((path, bytes));
}
}
report.unreadable.sort();
report.unreadable.dedup();
if apply && report.unreadable.is_empty() {
for (path, bytes) in std::mem::take(&mut report.unreferenced) {
match std::fs::remove_file(&path) {
Ok(()) => report.removed.push((path, bytes)),
Err(error) => report.failed.push((path, error.to_string())),
}
}
}
report
}
fn scan_file(path: &Path, referenced: &mut HashSet<Vec<u8>>) -> io::Result<()> {
let mut file = std::fs::File::open(path)?;
let mut buffer = vec![0_u8; SCAN_CHUNK + NAME_CARRY];
let mut carried = 0;
loop {
let read = file.read(&mut buffer[carried..])?;
if read == 0 {
break;
}
let filled = carried + read;
let window = &buffer[..filled];
let mut start = 0;
while let Some(offset) = find(&window[start..], NAME_PREFIX) {
let begin = start + offset;
let digits_start = begin + NAME_PREFIX.len();
let digits_end = window[digits_start..]
.iter()
.position(|byte| !byte.is_ascii_digit())
.map_or(filled, |len| digits_start + len);
let end = digits_end + NAME_SUFFIX.len();
if digits_end > digits_start && end <= filled && &window[digits_end..end] == NAME_SUFFIX
{
referenced.insert(window[begin..end].to_vec());
}
start = digits_start;
}
carried = filled.min(NAME_CARRY);
buffer.copy_within(filled - carried..filled, 0);
}
Ok(())
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
+5 -2
View File
@@ -139,7 +139,7 @@ fn canonical_store_root(dir: &Path, unreadable: &mut Vec<PathBuf>) -> Option<Pat
}
}
fn entry_has_type(
pub(crate) fn entry_has_type(
entry: &std::fs::DirEntry,
directory: bool,
unreadable: &mut Vec<PathBuf>,
@@ -159,7 +159,10 @@ fn entry_has_type(
}
}
fn directory_entries(dir: &Path, unreadable: &mut Vec<PathBuf>) -> Vec<std::fs::DirEntry> {
pub(crate) fn directory_entries(
dir: &Path,
unreadable: &mut Vec<PathBuf>,
) -> Vec<std::fs::DirEntry> {
let entries = match std::fs::symlink_metadata(dir) {
Err(error) if error.kind() == io::ErrorKind::NotFound => return Vec::new(),
Ok(meta) if meta.is_dir() => std::fs::read_dir(dir).ok(),
+7
View File
@@ -70,6 +70,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Added
- **Prune pasted images:** `codewhale sessions prune-images` lists pasted
clipboard images that no saved session, checkpoint, Runtime thread, draft or
history still names and that are older than 7 days (`--older-than-days`);
`--apply` deletes them. If any file it must scan cannot be read, it deletes
nothing. Run it while no Codewhale session is open. Thanks
[@Asteria1028](https://github.com/Asteria1028) for the report (Refs #7221).
- **Task wall clock pauses for answerable human waits (#5898):** a durable task run by the Runtime API server no longer spends its wall-time budget while a prompt on that run waits for an approval or user-input answer. `[tools] task_human_wait_cap_seconds` (default 1800, maximum 86400) bounds the total wait and fails an unanswered task with `human_wait_timeout`, releasing its worker. Only a Runtime serving its HTTP API pauses the clock; local Stdio, Socket and ACP control frontends keep the wall deadline, and a wall deadline crossed before the prompt still ends the task. The TUI's own tasks are unchanged. Thanks [@asto18089](https://github.com/asto18089).
- **MCP servers say why they are not connected:** the Runtime API's
`GET /v1/apps/mcp/servers` now includes a configured server's last
@@ -613,6 +619,7 @@ until the matching tag, packages, checksums, and release assets exist.
### Contributors
- **[@Asteria1028](https://github.com/Asteria1028)** — reported pasted clipboard images piling up with no way to remove them ([#7221](https://github.com/codewhale-hq/Codewhale/issues/7221)).
- **[@zhuowp](https://github.com/zhuowp)** (git author `z27014`) — added bounded HTTP rate-limit recovery and corrected retry deadlines, quota guidance and recovery receipts ([#7220](https://github.com/codewhale-hq/Codewhale/pull/7220)).
- **[@printf033](https://github.com/printf033)** — enabled exclusive rename on Android so Fleet publication does not fall back to SELinux-denied hard links (`fbe9f24ee`).
- **[@liuyy2](https://github.com/liuyy2)** — diagnosed case-sensitive model selection collisions in the provider picker ([#7214](https://github.com/codewhale-hq/Codewhale/issues/7214)).
+100 -2
View File
@@ -8,8 +8,8 @@
use codewhale_engine::runtime_options::RuntimeOptions;
use codewhale_engine::{
fleet, pricing, project_context, receipts, runtime_threads, session_export, session_manager,
session_secret_scrub, task_manager,
fleet, pricing, project_context, receipts, runtime_threads, session_export,
session_image_prune, session_manager, session_secret_scrub, task_manager,
};
use codewhale_runtime::config;
use std::collections::BTreeSet;
@@ -308,6 +308,17 @@ enum SessionsCommand {
#[arg(long, default_value_t = false)]
apply: bool,
},
/// Delete pasted clipboard images that no saved session, draft or history
/// still names. Reports what it would delete unless `--apply` is given.
/// Run it while no Codewhale session is open.
PruneImages {
/// Delete the listed images (default: report only)
#[arg(long, default_value_t = false)]
apply: bool,
/// Keep unreferenced images newer than this many days
#[arg(long, value_name = "DAYS", default_value_t = 7)]
older_than_days: u64,
},
/// Export a session as a full-fidelity tar.xz archive (complete context:
/// system prompt, messages, tool calls and results, plus artifacts)
Export {
@@ -2626,6 +2637,10 @@ async fn run_async_main_dispatch(
)
.await
}
Some(SessionsCommand::PruneImages {
apply,
older_than_days,
}) => run_sessions_prune_images(apply, older_than_days).await,
Some(SessionsCommand::Export {
id,
output,
@@ -9771,6 +9786,89 @@ fn run_sessions_scrub_secrets_blocking(
Ok(())
}
async fn run_sessions_prune_images(apply: bool, older_than_days: u64) -> Result<()> {
#[cfg(test)]
let ticket = crate::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(ticket);
run_sessions_prune_images_blocking(apply, older_than_days)
})
.await?
}
fn run_sessions_prune_images_blocking(apply: bool, older_than_days: u64) -> Result<()> {
let workspace = std::env::current_dir().context("Failed to read the current directory")?;
let image_dir = config::clipboard_images_dir(&workspace);
let manager = session_manager::SessionManager::default_location()?;
let runtime = runtime_threads::manager::RuntimeThreadManagerConfig::from_task_data_dir(
task_manager::default_tasks_dir(),
);
let mut roots = vec![
manager.sessions_dir().to_path_buf(),
runtime.data_dir.clone(),
];
roots.extend(image_dir.parent().map(Path::to_path_buf));
let grace = Duration::from_secs(older_than_days.saturating_mul(24 * 60 * 60));
let report = session_image_prune::prune_clipboard_images(&image_dir, &roots, grace, apply);
if report.images == 0 {
println!("No pasted images in {}.", image_dir.display());
return Ok(());
}
println!(
"{} pasted images ({}) in {}: {} still referenced, {} newer than {older_than_days} days.",
report.images,
format_bytes(report.image_bytes),
image_dir.display(),
report.referenced,
report.recent,
);
if !report.removed.is_empty() {
println!(
"Deleted {} unreferenced images ({}).",
report.removed.len(),
format_bytes(report.removed_bytes())
);
}
if !report.unreferenced.is_empty() {
println!(
"{} unreferenced images ({}) {}:",
report.unreferenced.len(),
format_bytes(report.unreferenced_bytes()),
if apply { "were kept" } else { "can be deleted" }
);
for (path, _) in &report.unreferenced {
println!(" {}", path.display());
}
if !apply && report.unreadable.is_empty() {
println!(
"Re-run with `{} --apply` to delete them (close open Codewhale sessions first).",
session_image_prune::PRUNE_IMAGES_COMMAND
);
}
}
if !report.failed.is_empty() {
println!(
"{} images could not be deleted and were left in place:",
report.failed.len()
);
for (path, error) in &report.failed {
println!(" {}: {error}", path.display());
}
}
if !report.unreadable.is_empty() {
println!(
"{} files or directories could not be read, so `--apply` deletes nothing until they can:",
report.unreadable.len()
);
for path in &report.unreadable {
println!(" {}", path.display());
}
}
println!("Scanned {} files for references.", report.files_scanned);
Ok(())
}
fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
use codewhale_palette as palette;
use colored::Colorize;
+26
View File
@@ -35,6 +35,32 @@
that explicit turn action; merely having a screenshot path or artifact does
not trigger background analysis.
### Pasted image storage
A pasted clipboard image is saved as `clipboard-<number>.png` under
`~/.codewhale/clipboard-images/` (or `<workspace>/clipboard-images/` when no
home directory is available), and the attachment names that absolute path.
The image bytes enter the request when the message is built, so a later
missing file never fails a resume; editing and resending an old message
reattaches from the path and reports a missing image instead of sending it.
Nothing deletes these files on its own. To reclaim space:
```sh
codewhale sessions prune-images # report only
codewhale sessions prune-images --apply # delete what the report lists
codewhale sessions prune-images --older-than-days 30
```
The command lists only images that no file under the Codewhale state
directory, the sessions directory or the Runtime task store still names
(saved sessions, checkpoints, archives, Runtime threads, the composer stash
and history) and that are older than the grace window (7 days by default),
so a draft or a running turn keeps its picture. If any file there cannot be
read, it reports what it found and deletes nothing. A file another process
still holds open (common on Windows) is reported and skipped. Close open
Codewhale sessions before `--apply`.
### How `Ctrl+V` reaches Codewhale
Most terminals own the paste chord, and Windows Terminal turns `Ctrl+V` into a
+10
View File
@@ -509,6 +509,16 @@ surfaces = { tui = "stable" }
docs = "docs/READ_MEDIA.md"
owner = "crates/tui/src/tui/image_preview.rs"
[[feature]]
id = "clipboard-image-prune"
name = "Pasted image cleanup"
summary = "codewhale sessions prune-images reports, and with --apply deletes, pasted clipboard images that no saved session, draft or history still names."
status = "experimental"
surfaces = { tui = "preview" }
since = "unreleased"
docs = "docs/READ_MEDIA.md#pasted-image-storage"
owner = "crates/engine/src/session_image_prune.rs"
[[feature]]
id = "memory"
name = "User memory"
+3
View File
@@ -189,6 +189,9 @@
"crates/engine/src/session_export.rs": {
"path_canonicalize": 1
},
"crates/engine/src/session_image_prune.rs": {
"std_fs": 4
},
"crates/engine/src/session_manager.rs": {
"path_canonicalize": 2,
"thread_sleep": 1