v0.2.2: detached-session cleanup (shell ccclean + voice/CLI cleanup)
add a detached-only session cleanup in BOTH surfaces — the cc shell kit and the
claudedo daemon — so stale detached claude-* sessions can be cleared from either.
- cc.sh: ccclean kills DETACHED claude-* sessions only (tmux #{session_attached}==0),
never attached; reports 'killed X, Y (2 detached); kept Z (attached)' or 'nothing to
clean'. complements cckl (kill ALL incl attached), which stays the deliberate typed
nuke. header updated; sources clean under bash + zsh.
- target.py: cleanup_detached() kills detached claude-* and returns (killed, kept)
lists. it and list_sessions() now share ONE tmux query, _claude_sessions(), which
returns (name, attached) pairs — single source for session enumeration.
- grammar: cleanup command (aliases detached/detach) routes to Action('system',
'cleanup') — daemon-control, never injects. bare 'cleanup' and 'system cleanup' both
accepted. 'clean'/'wipe' deliberately NOT used as aliases — they fuzzy-collide with
erase's 'clear'/'wipe' (0.8 ratio); 'detached' is distinct. confirm command added for
the opt-in confirm flow.
- daemon: system 'cleanup' -> _do_cleanup -> target.cleanup_detached, reports
'[SYSTEM] cleanup: killed ...; kept ... (attached)'. behavior.cleanup_confirm
(default false) announces and waits for a following 'confirm' before killing.
- CLI: 'claudedo cleanup' (self-contained tmux op, no running daemon needed).
safety model: detached-only means a misheard voice cleanup can NEVER kill the active
(attached) session. the only kill-attached path remains the shell cckl.
Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
parent
1a593b95fa
commit
509d3ad3b3
@ -83,6 +83,7 @@ claudedo reload # reload config.toml + contexts.toml in a running daem
|
||||
claudedo set <name> # set the sticky target -> claude-<name> (alias: switch)
|
||||
claudedo unset # clear the sticky target
|
||||
claudedo list # list running claude-* sessions
|
||||
claudedo cleanup # kill DETACHED claude-* sessions (never attached)
|
||||
claudedo test-audio # verify the mic capture path
|
||||
claudedo test-tone # play each earcon (verify the audio-OUT path)
|
||||
```
|
||||
@ -133,6 +134,7 @@ said "okay clouds"), the heard line notes which phrase it assumed —
|
||||
| `reload` | re-read `config.toml` + `contexts.toml` live (no daemon restart, model stays loaded) |
|
||||
| `system status` | print mode / target / model / context count to the console (daemon-control; never injects) |
|
||||
| `system reload [config\|contexts]` | reload one or both config files |
|
||||
| `cleanup` (alias `detached`/`detach`, also `system cleanup`) | kill **detached** `claude-*` sessions only — never an attached one |
|
||||
| `commands` (alias `help`/`menu`) | print the voice-command menu to the console |
|
||||
| `customs` (alias `custom`) | list the loaded context names |
|
||||
| `version` | print the claudedo version to the console |
|
||||
@ -175,7 +177,8 @@ cc <name> # attach/create claude-<name>; writes ~/.claude-active
|
||||
ccr <name> # re-attach an existing claude-<name> only
|
||||
ccl # list claude-* sessions
|
||||
cck <name> # kill claude-<name>
|
||||
cckl # kill all claude-* sessions
|
||||
ccclean # kill DETACHED claude-* sessions only (never attached) — safe cleanup
|
||||
cckl # kill ALL claude-* sessions (including attached)
|
||||
```
|
||||
|
||||
## Contexts (named reference blurbs)
|
||||
|
||||
@ -88,6 +88,11 @@ print_heard = false
|
||||
context_multiline = true
|
||||
# separator inserted between blurb and instruction when context_multiline = false.
|
||||
context_separator = " — "
|
||||
# the `cleanup` / `detached` command kills DETACHED claude-* sessions only (never an
|
||||
# attached one — a misheard cleanup can't nuke the active session). default false:
|
||||
# kill immediately (it's detached-only, so it's safe). set true to announce the
|
||||
# detached set and wait for a following `confirm` before killing.
|
||||
cleanup_confirm = false
|
||||
|
||||
[sound]
|
||||
# earcons — short confirmation tones on daemon events so you get eyes-free feedback
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "claudedo"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
31
shell/cc.sh
31
shell/cc.sh
@ -11,7 +11,8 @@
|
||||
# ccr <name> reattach only (error if it doesn't exist); writes ~/.claude-active
|
||||
# ccl list running claude- sessions
|
||||
# cck <name> kill claude-<name>
|
||||
# cckl kill ALL claude- sessions
|
||||
# ccclean kill DETACHED claude- sessions only (never attached) — safe cleanup
|
||||
# cckl kill ALL claude- sessions (including attached)
|
||||
|
||||
cc() {
|
||||
if [ -z "$1" ]; then
|
||||
@ -60,6 +61,34 @@ cck() {
|
||||
fi
|
||||
}
|
||||
|
||||
ccclean() {
|
||||
killed=""
|
||||
kept=""
|
||||
while read -r name attached; do
|
||||
case "$name" in
|
||||
claude-*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ "$attached" = "0" ]; then
|
||||
if tmux kill-session -t "$name" 2>/dev/null; then
|
||||
killed="${killed:+$killed, }$name"
|
||||
fi
|
||||
else
|
||||
kept="${kept:+$kept, }$name"
|
||||
fi
|
||||
done <<EOF
|
||||
$(tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null)
|
||||
EOF
|
||||
if [ -z "$killed" ]; then
|
||||
echo "nothing to clean (no detached sessions)"
|
||||
else
|
||||
n=$(printf '%s' "$killed" | awk -F', ' '{print NF}')
|
||||
msg="killed $killed ($n detached)"
|
||||
[ -n "$kept" ] && msg="$msg; kept $kept (attached)"
|
||||
echo "$msg"
|
||||
fi
|
||||
}
|
||||
|
||||
cckl() {
|
||||
tmux ls 2>/dev/null | grep '^claude-' | cut -d: -f1 | while read -r s; do
|
||||
tmux kill-session -t "$s" && echo "killed $s"
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
@ -127,6 +127,18 @@ def cmd_test_tone(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_cleanup(_args: argparse.Namespace) -> int:
|
||||
killed, kept = target.cleanup_detached()
|
||||
if not killed:
|
||||
print("nothing to clean (no detached sessions)")
|
||||
return 0
|
||||
msg = f"killed {', '.join(killed)}"
|
||||
if kept:
|
||||
msg += f"; kept {', '.join(kept)} (attached)"
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_reload(_args: argparse.Namespace) -> int:
|
||||
if daemon.reload_running():
|
||||
print("signalled claudedo to reload config + contexts")
|
||||
@ -269,6 +281,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sub.add_parser("install", help="re-run the bootstrap (install.sh)").set_defaults(func=cmd_install)
|
||||
sub.add_parser("unset", help="clear the sticky target session").set_defaults(func=cmd_unset)
|
||||
sub.add_parser("list", help="list running claude-* sessions").set_defaults(func=cmd_list)
|
||||
sub.add_parser("cleanup", help="kill detached claude-* sessions (never attached)"
|
||||
).set_defaults(func=cmd_cleanup)
|
||||
|
||||
for verb in ("set", "switch"):
|
||||
sp_set = sub.add_parser(verb, help="set the sticky target session")
|
||||
|
||||
@ -58,6 +58,7 @@ class Config:
|
||||
print_heard: bool
|
||||
context_multiline: bool
|
||||
context_separator: str
|
||||
cleanup_confirm: bool
|
||||
sound_enabled: bool
|
||||
sound_on_wake: bool
|
||||
sound_on_accept: bool
|
||||
@ -139,6 +140,7 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
||||
print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)),
|
||||
context_multiline=bool(_require(raw, "behavior", "context_multiline", (bool,), True)),
|
||||
context_separator=str(_require(raw, "behavior", "context_separator", (str,), " — ")),
|
||||
cleanup_confirm=bool(_require(raw, "behavior", "cleanup_confirm", (bool,), False)),
|
||||
sound_enabled=bool(_require(raw, "sound", "enabled", (bool,), True)),
|
||||
sound_on_wake=bool(_require(raw, "sound", "on_wake", (bool,), False)),
|
||||
sound_on_accept=bool(_require(raw, "sound", "on_accept", (bool,), True)),
|
||||
|
||||
@ -125,6 +125,7 @@ class Daemon:
|
||||
self.mode = config.mode
|
||||
self._stop = False
|
||||
self._reload_pending = False
|
||||
self._cleanup_pending = False
|
||||
self._transcriber: Transcriber | None = None
|
||||
self._device: int | None = None
|
||||
self._ptt = _PTTKey()
|
||||
@ -411,8 +412,48 @@ class Daemon:
|
||||
self._console.emit(SYSTEM, f"{blue}: mode {self.mode}, sticky {sticky}, "
|
||||
f"model {cfg.stt_model}, {len(self._contexts)} contexts")
|
||||
return
|
||||
if arg == "cleanup":
|
||||
self._do_cleanup()
|
||||
return
|
||||
if arg == "confirm":
|
||||
blue = self._console.paint("cleanup", "brightblue")
|
||||
if self._cleanup_pending:
|
||||
self._run_cleanup(blue)
|
||||
else:
|
||||
self._console.emit(SYSTEM, f"{blue}: nothing pending to confirm")
|
||||
return
|
||||
self._console.emit(SYSTEM, f"unknown system command {arg!r}", "red")
|
||||
|
||||
def _do_cleanup(self) -> None:
|
||||
"""kill detached claude-* sessions (never attached), report killed + kept.
|
||||
|
||||
detached-only is the safety model: a misheard voice cleanup cannot nuke the
|
||||
active (attached) session. with behavior.cleanup_confirm the daemon announces
|
||||
the detached set and waits for a following ``confirm`` instead of killing now.
|
||||
"""
|
||||
blue = self._console.paint("cleanup", "brightblue")
|
||||
if self.config.cleanup_confirm:
|
||||
pending = [n for n, attached in target._claude_sessions() if not attached]
|
||||
if not pending:
|
||||
self._console.emit(SYSTEM, f"{blue}: nothing to clean (no detached sessions)")
|
||||
return
|
||||
self._cleanup_pending = True
|
||||
self._console.emit(SYSTEM, f"{blue}: would kill {', '.join(sorted(pending))} "
|
||||
f"— say 'confirm' to proceed")
|
||||
return
|
||||
self._run_cleanup(blue)
|
||||
|
||||
def _run_cleanup(self, blue: str) -> None:
|
||||
killed, kept = target.cleanup_detached()
|
||||
self._cleanup_pending = False
|
||||
if not killed:
|
||||
self._console.emit(SYSTEM, f"{blue}: nothing to clean (no detached sessions)")
|
||||
return
|
||||
msg = f"{blue}: killed {', '.join(killed)}"
|
||||
if kept:
|
||||
msg += f"; kept {', '.join(kept)} (attached)"
|
||||
self._console.emit(SYSTEM, msg)
|
||||
|
||||
def _timing(self) -> str:
|
||||
"""compact STT latency suffix for heard lines (transcribe ms on audio secs)"""
|
||||
return f"({self._last_stt_ms:.0f}ms/{self._last_audio_s:.1f}s)"
|
||||
|
||||
@ -58,6 +58,8 @@ _CONTEXT_VERBS = ("context", "prepare")
|
||||
_RELOAD_VERBS = ("reload",)
|
||||
_SYSTEM_VERBS = ("system",)
|
||||
_RELOAD_SCOPES = ("config", "contexts")
|
||||
_CLEANUP_VERBS = ("detached", "detach", "cleanup")
|
||||
_CONFIRM_VERBS = ("confirm",)
|
||||
|
||||
# every command/synonym word, for biasing the STT toward the vocabulary we expect.
|
||||
_COMMAND_WORDS = (
|
||||
@ -65,8 +67,8 @@ _COMMAND_WORDS = (
|
||||
+ _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS
|
||||
+ _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS
|
||||
+ _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _VERSION_VERBS
|
||||
+ _CONTEXT_VERBS + _RELOAD_VERBS + _SYSTEM_VERBS + _RELOAD_SCOPES
|
||||
+ _SELECT_VERBS + ("ptt", "listen")
|
||||
+ _CONTEXT_VERBS + _RELOAD_VERBS + _SYSTEM_VERBS + _RELOAD_SCOPES + _CLEANUP_VERBS
|
||||
+ _CONFIRM_VERBS + _SELECT_VERBS + ("ptt", "listen")
|
||||
+ ("one", "two", "three", "four")
|
||||
)
|
||||
DEFAULT_FILLER = ("select", "use", "choose")
|
||||
@ -161,6 +163,7 @@ def command_menu() -> list[tuple[str, str]]:
|
||||
("reload", "re-read config.toml + contexts.toml live"),
|
||||
("system status", "print mode/target/model/contexts to the console"),
|
||||
("system reload [config|contexts]", "reload one or both config files"),
|
||||
("cleanup / detached", "kill detached claude-* sessions (never attached)"),
|
||||
("commands / customs", "this menu / list loaded contexts"),
|
||||
("version", "print the claudedo version"),
|
||||
]
|
||||
@ -274,6 +277,8 @@ def _match_system(rest: list[str], threshold: float) -> Action | None:
|
||||
return Action("system", ("reload", inner.arg))
|
||||
if _fuzzy_in(head, ("status", "state"), threshold):
|
||||
return Action("system", "status")
|
||||
if _fuzzy_in(head, _CLEANUP_VERBS, threshold):
|
||||
return Action("system", "cleanup")
|
||||
return Action("system", ("unknown", head))
|
||||
|
||||
|
||||
@ -293,6 +298,10 @@ def match_command(remainder: str, threshold: float) -> Action | None:
|
||||
|
||||
if _fuzzy_in(head, _SYSTEM_VERBS, threshold):
|
||||
return _match_system(rest, threshold)
|
||||
if _fuzzy_in(head, _CLEANUP_VERBS, threshold):
|
||||
return Action("system", "cleanup")
|
||||
if _fuzzy_in(head, _CONFIRM_VERBS, threshold):
|
||||
return Action("system", "confirm")
|
||||
if _fuzzy_in(head, _RELOAD_VERBS, threshold):
|
||||
return _match_reload(rest, threshold, bare_default="all")
|
||||
if _fuzzy_in(head, _CONTEXT_VERBS, threshold) and rest:
|
||||
|
||||
@ -75,14 +75,54 @@ def session_exists(name: str) -> bool:
|
||||
|
||||
def list_sessions() -> list[str]:
|
||||
"""return the names of all running claude-* tmux sessions (sorted)"""
|
||||
return sorted(name for name, _attached in _claude_sessions())
|
||||
|
||||
|
||||
def _claude_sessions() -> list[tuple[str, bool]]:
|
||||
"""the single tmux query for claude-* sessions: (name, attached) pairs.
|
||||
|
||||
one source of truth for session enumeration — list_sessions() and the detached
|
||||
cleanup both build on this. attached is True when at least one client is attached
|
||||
(tmux #{session_attached} > 0). returns [] if tmux isn't reachable.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||
["tmux", "list-sessions", "-F", "#{session_name} #{session_attached}"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
names = result.stdout.decode("utf-8", "replace").splitlines()
|
||||
return sorted(n for n in names if n.startswith(SESSION_PREFIX))
|
||||
out: list[tuple[str, bool]] = []
|
||||
for line in result.stdout.decode("utf-8", "replace").splitlines():
|
||||
parts = line.rsplit(" ", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
name, attached = parts
|
||||
if name.startswith(SESSION_PREFIX):
|
||||
out.append((name, attached.strip() != "0"))
|
||||
return out
|
||||
|
||||
|
||||
def cleanup_detached() -> tuple[list[str], list[str]]:
|
||||
"""kill every DETACHED claude-* session, never an attached one. returns the
|
||||
(killed, kept_attached) name lists (both sorted) for reporting.
|
||||
|
||||
detached-only is the safety model: a misheard voice ``cleanup`` cannot nuke the
|
||||
active session, which is attached. the kill-including-attached path stays the shell
|
||||
``cckl`` (deliberate, typed).
|
||||
"""
|
||||
killed: list[str] = []
|
||||
kept: list[str] = []
|
||||
for name, attached in _claude_sessions():
|
||||
if attached:
|
||||
kept.append(name)
|
||||
continue
|
||||
result = subprocess.run(
|
||||
["tmux", "kill-session", "-t", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
killed.append(name)
|
||||
return sorted(killed), sorted(kept)
|
||||
|
||||
|
||||
def resolve(one_shot: str | None = None, auto_target: bool = False) -> tuple[str | None, str]:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user