Compare commits

..

No commits in common. "main" and "v0.2.1" have entirely different histories.
main ... v0.2.1

10 changed files with 9 additions and 152 deletions

View File

@ -83,7 +83,6 @@ 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)
```
@ -134,7 +133,6 @@ 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 |
@ -177,8 +175,7 @@ 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>
ccclean # kill DETACHED claude-* sessions only (never attached) — safe cleanup
cckl # kill ALL claude-* sessions (including attached)
cckl # kill all claude-* sessions
```
## Contexts (named reference blurbs)

View File

@ -88,11 +88,6 @@ 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

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "claudedo"
version = "0.2.2"
version = "0.2.1"
description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
readme = "README.md"
requires-python = ">=3.10"

View File

@ -11,8 +11,7 @@
# ccr <name> reattach only (error if it doesn't exist); writes ~/.claude-active
# ccl list running claude- sessions
# cck <name> kill claude-<name>
# ccclean kill DETACHED claude- sessions only (never attached) — safe cleanup
# cckl kill ALL claude- sessions (including attached)
# cckl kill ALL claude- sessions
cc() {
if [ -z "$1" ]; then
@ -61,34 +60,6 @@ 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"

View File

@ -1,3 +1,3 @@
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
__version__ = "0.2.2"
__version__ = "0.2.1"

View File

@ -127,18 +127,6 @@ 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")
@ -281,8 +269,6 @@ 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")

View File

@ -58,7 +58,6 @@ 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
@ -140,7 +139,6 @@ 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)),

View File

@ -125,7 +125,6 @@ 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()
@ -412,48 +411,8 @@ 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)"

View File

@ -58,8 +58,6 @@ _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 = (
@ -67,8 +65,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 + _CLEANUP_VERBS
+ _CONFIRM_VERBS + _SELECT_VERBS + ("ptt", "listen")
+ _CONTEXT_VERBS + _RELOAD_VERBS + _SYSTEM_VERBS + _RELOAD_SCOPES
+ _SELECT_VERBS + ("ptt", "listen")
+ ("one", "two", "three", "four")
)
DEFAULT_FILLER = ("select", "use", "choose")
@ -163,7 +161,6 @@ 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"),
]
@ -277,8 +274,6 @@ 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))
@ -298,10 +293,6 @@ 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:

View File

@ -75,54 +75,14 @@ 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} #{session_attached}"],
["tmux", "list-sessions", "-F", "#{session_name}"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
return []
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)
names = result.stdout.decode("utf-8", "replace").splitlines()
return sorted(n for n in names if n.startswith(SESSION_PREFIX))
def resolve(one_shot: str | None = None, auto_target: bool = False) -> tuple[str | None, str]: