clean

A command that finds abandoned worker-session folders and leftover Git worktrees. A worktree is a separate working copy of a Git repository used to isolate a task.

In plain words
What is it for?
Use it to inspect stale sessions and temporary worktrees, then remove the abandoned ones when needed. It supports a dry-run before deletion.
Why use it?
Interrupted or crashed workers can leave sessions marked as running and temporary worktrees behind, making status information misleading and wasting disk space.

Command

Install

Getting it into your agent

One page per mod, every tool's command on it. A separate URL per tool would split the same page into five that compete with each other.

agentmods
npx agentmods add commands/rbinar/cli-dispatch/clean
Clone the repo
git clone --depth 1 https://github.com/rbinar/cli-dispatch
Per session 22 Only the description is in the session, so the agent can decide to use it. The body loads when it is invoked.
When invoked 6,025 The whole file, excluding the scripts and references it only reads on demand.
Security scan C 1 finding. Scan, not verified.
Origin original No closer match found in the catalogue.
Token cost

What it costs to keep this loaded

Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.

ModelPer sessionOnce invoked
Fable 5 $0.00022 $0.06025
Opus 5 $0.00011 $0.03012
Sonnet 5 $0.00004 $0.01205
Haiku 4.5 $0.00002 $0.00602

Measured yesterday against content hash 70bcf3053916, method: parsed. Prices are Anthropic first-party input rates as of 2026-08-30, from the pricing page.

Security

Grade C, and why

clean scanned grade C with 1 finding against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured yesterday.

A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.

Recursive force deletehighDestructive command

rm -rf with a variable or a broad path is one typo away from removing the wrong tree.

rm -rf "$wt"
plugins/cli-dispatch/commands/clean.md · 354 lines

How it starts

The opening of the file, as written. The whole thing — 354 lines — stays where its author put it; the contents beside it link to each section on GitHub.

cli-dispatch clean

Worker sessions live under ~/.cache/cli-dispatch/sessions/<id>/. A worker that was killed before it finalized (Ctrl-C, the parent CLI closed mid-run, crash, watchdog kill, or a codex/ OpenCode/Copilot provisional cx-<ts>-<pid>/oc-<ts>-<pid>/cp-<ts>-<pid> dir that never relocated to its thread-id/session-id) leaves status.json stuck at state:"running" forever — it shows up as stale in /cli-dispatch:sessions and the dashboard, and never gets removed. This command finds and (with --remove) deletes them.

It also sweeps leftover worktree artifacts: real-repo-changing delegations (via /cli-dispatch:run — the deterministic runner — or a plain *-agent CLI) are isolated in a git worktree named <backend>-wt-* (ds-wt-*, ag-wt-*, cx-wt-*, oc-wt-*, cp-wt-*) under /tmp / $TMPDIR ($env:TEMP on Windows). A delegation that crashes or is killed before its own cleanup leaves that worktree behind forever; this sweep finds and (with --remove) deletes those too.

Detection = status.json mtime: state:"running" with no write for longer than the stale window ⇒ dead. Default is a dry-run (lists only); pass --remove to delete.

  • --remove — actually delete (default: dry-run, just list). Applies to both the session cleanup and the worktree sweep.
  • --stale-secs N — idle window before a running session dir counts as stale (default 600 = 10 min; deliberately larger than the dashboard's 90 s so a live-but-quiet turn is never deleted).
  • --older-than DAYS — ALSO prune finished (done/error) session dirs whose meta.startedAt is older than DAYS. Omit to leave all finished sessions alone.
  • --preserve-verdicts — archive verdict.json and verdict-diff.patch into <sessions-root>/verdict-archive/ before removal (default; accepted for compatibility).
  • --no-preserve-verdicts — remove session dirs without archiving verdict files.
  • --worktree-days N — idle window (dir mtime) before a *-wt-* worktree artifact counts as stale (default 3 days).
  • --skip-worktrees — disable the worktree-artifact sweep entirely (session cleanup only).
  • --quiet — suppress non-essential output for both the session cleanup and the worktree sweep (used by the scheduled auto-clean).

A genuinely-running worker (recent status.json write) is NEVER touched. A worktree with uncommitted changes (git status --porcelain non-empty) is NEVER touched either — it is reported as DIRTY (skipped, uncommitted changes) so you can rescue it by hand. A *-wt-* dir that isn't a valid git worktree (broken/missing .git) is also left alone and reported as SKIP (git status failed — not a valid worktree?). After deleting a worktree, if its source repo can be resolved from the worktree's .git gitdir pointer, git worktree prune is run against that source repo (best-effort — silently skipped if the source repo no longer exists or can't be resolved) so the source repo's own git worktree list doesn't keep a dangling administrative entry.

ARGS="$*"   # pass through the command args (e.g. --remove --older-than 7)
REMOVE=0; STALE_SECS=600; OLDER_DAYS=0; PRESERVE_VERDICTS=1; WT_DAYS=3; SKIP_WORKTREES=0; QUIET=0
set -- $ARGS
while [ "$#" -gt 0 ]; do
  case "$1" in
    --remove) REMOVE=1; shift;;
    --stale-secs) STALE_SECS="$2"; shift 2;;
    --older-than) OLDER_DAYS="$2"; shift 2;;
    --preserve-verdicts) PRESERVE_VERDICTS=1; shift;;
    --no-preserve-verdicts) PRESERVE_VERDICTS=0; shift;;
    --worktree-days) WT_DAYS="$2"; shift 2;;
    --skip-worktrees) SKIP_WORKTREES=1; shift;;
    --quiet) QUIET=1; shift;;
    *) shift;;
  esac
done
case "$STALE_SECS" in ''|*[!0-9]*) STALE_SECS=600;; esac
case "$OLDER_DAYS"  in ''|*[!0-9]*) OLDER_DAYS=0;; esac
case "$WT_DAYS"     in ''|*[!0-9]*) WT_DAYS=3;; esac

# ---- worktree artifact sweep -------------------------------------------------------------
# Real-repo-changing delegations (via /cli-dispatch:run or a plain *-agent CLI) are isolated
# in a git worktree named <backend>-wt-* under /tmp / $TMPDIR; one that crashes or is killed
# before its own cleanup leaves that worktree behind forever. Dirty worktrees (uncommitted
# changes) are never touched.
wtlog() { [ "$QUIET" -eq 1 ] || echo "$@"; }
if [ "$SKIP_WORKTREES" -ne 1 ]; then
  GIT_BIN="$(command -v git 2>/dev/null || true)"
  if [ -z "$GIT_BIN" ]; then
    for cand in /usr/bin/git /opt/homebrew/bin/git /usr/local/bin/git; do
      [ -x "$cand" ] && { GIT_BIN="$cand"; break; }
    done
  fi
  WT_FOUND=0; WT_DIRTY=0; WT_REMOVED=0; WT_SKIPPED=0
  PRUNED_REPOS=""
  sweep_wt_dir() {
    local base="$1" wt gitdir_line src_repo git_out git_rc
    [ -d "$base" ] || return 0
    while IFS= read -r -d '' wt; do
      [ -d "$wt" ] || continue
      WT_FOUND=$((WT_FOUND + 1))
      if [ -z "$GIT_BIN" ]; then
        wtlog "  SKIP (git unavailable) $wt"; WT_SKIPPED=$((WT_SKIPPED + 1)); continue
      fi
      git_rc=0
      git_out="$("$GIT_BIN" -C "$wt" status --porcelain 2>/dev/null)" || git_rc=$?
      if [ "$git_rc" -ne 0 ]; then
        wtlog "  SKIP (git status failed — not a valid worktree?) $wt"; WT_SKIPPED=$((WT_SKIPPED + 1)); continue
      fi
      if [ -n "$git_out" ]; then
        wtlog "  DIRTY (skipped, uncommitted changes) $wt"; WT_DIRTY=$((WT_DIRTY + 1)); continue
      fi
      wtlog "  worktree stale (clean, idle > ${WT_DAYS}d): $wt"
      if [ "$REMOVE" -eq 1 ]; then
        src_repo=""
        if [ -f "$wt/.git" ]; then
          gitdir_line="$(sed -n 's/^gitdir: //p' "$wt/.git" 2>/dev/null | head -1)"
          case "$gitdir_line" in
            */.git/worktrees/*) src_repo="${gitdir_line%/.git/worktrees/*}";;
          esac
        fi
        rm -rf "$wt"
        WT_REMOVED=$((WT_REMOVED + 1))
        if [ -n "$src_repo" ] && [ -d "$src_repo" ]; then
          case " $PRUNED_REPOS " in
            *" $src_repo "*) ;;
            *) "$GIT_BIN" -C "$src_repo" worktree prune >/dev/null 2>&1 || true; PRUNED_REPOS="$PRUNED_REPOS $src_repo";;
          esac
        fi
      fi
    done < <(find "$base" -mindepth 1 -maxdepth 1 -type d -name '*-wt-*' -mtime +"$WT_DAYS" -print0 2>/dev/null)
  }
  wtlog "worktree artifact sweep (pattern *-wt-*, older than ${WT_DAYS}d):"
  sweep_wt_dir "/tmp"
  if [ -n "${TMPDIR:-}" ] && [ "${TMPDIR%/}" != "/tmp" ]; then sweep_wt_dir "${TMPDIR%/}"; fi
  WT_ELIGIBLE=$((WT_FOUND - WT_DIRTY - WT_SKIPPED))
  if [ "$WT_FOUND" -eq 0 ]; then
    wtlog "  none found."
  elif [ "$REMOVE" -eq 1 ]; then
    wtlog "  removed $WT_REMOVED worktree(s), skipped $WT_DIRTY dirty, $WT_SKIPPED unreadable."
  else
    wtlog "  DRY-RUN — $WT_ELIGIBLE of $WT_FOUND candidate(s) would be deleted ($WT_DIRTY dirty, $WT_SKIPPED unreadable — both kept). Re-run with --remove to delete."
  fi
fi

CACHE="${XDG_CACHE_HOME:-$HOME/.cache}"
ROOT="${CLI_DISPATCH_SESSIONS_DIR:-${CLAUDE_DS_SESSIONS_DIR:-}}"
[ -n "$ROOT" ] || { ROOT="$CACHE/cli-dispatch/sessions"; [ -d "$ROOT" ] || ROOT="$CACHE/claude-ds/sessions"; }
[ -d "$ROOT" ] || { echo "(no sessions dir: $ROOT)"; exit 0; }

REMOVE=$REMOVE STALE_SECS=$STALE_SECS OLDER_DAYS=$OLDER_DAYS PRESERVE_VERDICTS=$PRESERVE_VERDICTS ROOT="$ROOT" node <<'EOF'
const fs=require('fs'), path=require('path')
const root=process.env.ROOT, remove=process.env.REMOVE==='1', preserveVerdicts=process.env.PRESERVE_VERDICTS==='1'
const staleSecs=+process.env.STALE_SECS, olderDays=+process.env.OLDER_DAYS
const now=Date.now()
const read=p=>{try{return JSON.parse(fs.readFileSync(p,'utf8'))}catch{return{}}}
const hasVerdictPatch = (dir)=>{try{return fs.statSync(path.join(dir,'verdict-diff.patch')).size>0}catch{return false}}
const hasVerdictJson = (dir)=>{try{return fs.statSync(path.join(dir,'verdict.json')).isFile()}catch{return false}}
let stale=[], old=[], kept=0, patchCandidates=0
for(const d of fs.readdirSync(root)){
  if (d==='verdict-archive') continue
  const dir=path.join(root,d); let s
  try{ if(!fs.statSync(dir).isDirectory()) continue }catch{ continue }
  const st=read(path.join(dir,'status.json')), m=read(path.join(dir,'meta.json'))
  const state=st.state||m.state||'?'
  const verdictPatch=hasVerdictPatch(dir), verdictJson=hasVerdictJson(dir)
  const verdictMarker = verdictPatch ? '  ⚠ has verdict patch' : ''
  let mtime=0; try{ mtime=fs.statSync(path.join(dir,'status.json')).mtimeMs }catch{}
  const idle=mtime?Math.round((now-mtime)/1000):null
  if(state==='running' && mtime && (now-mtime > staleSecs*1000)){
    if(verdictPatch) patchCandidates++
    stale.push({d,backend:st.backend||m.backend||'?',idle,verdictPatch,verdictJson,verdictMarker}); continue
  }
  if(olderDays>0 && (state==='done'||state==='error')){
    const started=Date.parse(m.startedAt||'')||0
    if(started && (now-started > olderDays*86400*1000)){
      if(verdictPatch) patchCandidates++
      old.push({d,backend:st.backend||m.backend||'?',state,started:m.startedAt,verdictPatch,verdictJson,verdictMarker}); continue
    }
  }
  kept++
}
const archiveRoot=path.join(root,'verdict-archive')
const rm=(d)=>fs.rmSync(path.join(root,d),{recursive:true,force:true})
const days=s=>s==null?'?':(s>86400?(s/86400).toFixed(1)+'d':(s/3600).toFixed(1)+'h')
console.log(`root: ${root}`)
console.log(`stale (running but dead, idle > ${staleSecs}s): ${stale.length}`)
for(const x of stale) console.log(`  ${x.backend.padEnd(11)} ${x.d}  idle ${days(x.idle)}${x.verdictMarker}`)
if(olderDays>0){
  console.log(`old finished (done/error, started > ${olderDays}d ago): ${old.length}`)
  for(const x of old) console.log(`  ${x.backend.padEnd(11)} ${x.state.padEnd(6)} ${x.d}  ${x.started}${x.verdictMarker}`)
}
const targets=[...stale, ...old]
if(!targets.length){ console.log('nothing to clean.'); process.exit(0) }
if(remove){
  let n=0, archived=0
  for(const x of targets){
    if(preserveVerdicts && (x.verdictPatch||x.verdictJson)){
      let copied=false
      try{
        fs.mkdirSync(archiveRoot,{recursive:true})
        if(x.verdictPatch){ fs.copyFileSync(path.join(root,x.d,'verdict-diff.patch'),path.join(archiveRoot,`${x.d}.patch`)); copied=true }
        if(x.verdictJson){ fs.copyFileSync(path.join(root,x.d,'verdict.json'),path.join(archiveRoot,`${x.d}.json`)); copied=true }
      }catch(e){ console.log(`  note: archive failed for ${x.d}: ${e.message}`) }
      if(copied) archived++
    }
    try{ rm(x.d); n++ }catch(e){ console.log(`  FAILED ${x.d}: ${e.message}`) }
  }
  const archiveSummary = preserveVerdicts ? `archived verdicts for ${archived} session(s).` : 'verdict archiving disabled.'
  console.log(`\nremoved ${n}/${targets.length} dir(s). kept ${kept} live/recent. ${archiveSummary}`)
}else{
  console.log(`\nDRY-RUN — nothing deleted. Re-run with --remove to delete the ${targets.length} dir(s) above.`)
  if (patchCandidates) {
    if (preserveVerdicts) {
      console.log(`note: ${patchCandidates} candidate(s) carry a verdict-diff.patch (possible unapplied recovery diff) — they will be archived on removal; pass --no-preserve-verdicts to skip archiving.`)
    } else {
      console.log(`note: ${patchCandidates} candidate(s) carry a verdict-diff.patch (possible unapplied recovery diff) — verdict archiving is disabled by --no-preserve-verdicts.`)
    }
  }
}
EOF

Read the full file on GitHub · 354 lines

Changes

What this file has done since we first saw it

Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.

  1. yesterday First seen · 354 lines · 22 tokens per session scan C 70bcf3053916

Subscribe to this mod's changes

clean is a command published in the GitHub repository rbinar/cli-dispatch (5 stars, last pushed 13d ago), licensed MIT. It adds 22 tokens to every session and 6,025 once invoked, about $0.0001 per session on Opus 5. A static security scan graded it C with 1 finding (recursive force delete). No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-31.