diff --git a/README.md b/README.md index 2f0fc41..7a8bff1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ claudedo reload # reload config.toml + contexts.toml in a running daem claudedo set # set the sticky target -> claude- (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 # attach/create claude-; writes ~/.claude-active ccr # re-attach an existing claude- only ccl # list claude-* sessions cck # kill claude- -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) diff --git a/config.toml b/config.toml index b4dbeff..e1aaab5 100644 --- a/config.toml +++ b/config.toml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6439486..a9a052f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/shell/cc.sh b/shell/cc.sh index 29a94f9..fae5546 100644 --- a/shell/cc.sh +++ b/shell/cc.sh @@ -11,7 +11,8 @@ # ccr reattach only (error if it doesn't exist); writes ~/.claude-active # ccl list running claude- sessions # cck kill claude- -# 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 </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" diff --git a/src/claudedo/__init__.py b/src/claudedo/__init__.py index d08b05d..baa640b 100644 --- a/src/claudedo/__init__.py +++ b/src/claudedo/__init__.py @@ -1,3 +1,3 @@ """claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)""" -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/src/claudedo/__main__.py b/src/claudedo/__main__.py index 41167e7..04d30c1 100644 --- a/src/claudedo/__main__.py +++ b/src/claudedo/__main__.py @@ -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") diff --git a/src/claudedo/config.py b/src/claudedo/config.py index 04f0b6f..6beac4a 100644 --- a/src/claudedo/config.py +++ b/src/claudedo/config.py @@ -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)), diff --git a/src/claudedo/daemon.py b/src/claudedo/daemon.py index ed07b03..0bcf1fd 100644 --- a/src/claudedo/daemon.py +++ b/src/claudedo/daemon.py @@ -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)" diff --git a/src/claudedo/grammar.py b/src/claudedo/grammar.py index a5abae3..7bd26c8 100644 --- a/src/claudedo/grammar.py +++ b/src/claudedo/grammar.py @@ -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: diff --git a/src/claudedo/target.py b/src/claudedo/target.py index 61852ea..f2d42e6 100644 --- a/src/claudedo/target.py +++ b/src/claudedo/target.py @@ -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]: