feat: v0.1.1 sticky vs one-shot targeting, filler words, auto-single
redefine targeting: 'set' (aliases sticky/switch) is the persistent sticky default (~/.claude-active); 'target <name> <command>' is a one-shot override that routes a single command without changing the sticky default. add 'unset' and 'list'. resolution moves to a single target.resolve(one_shot) implementing the order: one-shot -> sticky-if-exists -> only-session auto -> ambiguous/none do nothing (never falls through, never injects into a missing session). grammar.parse now returns ParsedCommand(one_shot, action) and skips optional leading filler words (config behavior.filler_words: select/use/choose), with a filler-before-digit still meaning the select command. CLI gains set/unset/list (switch kept as a set alias). daemon console shows the targeting reason per line. docs updated; no stale 'target = sticky' wording remains. bump to 0.1.1. Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
parent
17db65858e
commit
43b36d2a0b
34
README.md
34
README.md
@ -20,7 +20,7 @@ mic (WSLg/PulseAudio RDPSource)
|
|||||||
-> sounddevice capture
|
-> sounddevice capture
|
||||||
-> faster-whisper (local STT, on-device)
|
-> faster-whisper (local STT, on-device)
|
||||||
-> wake gate: utterance must start with a wake phrase, else DISCARD locally
|
-> wake gate: utterance must start with a wake phrase, else DISCARD locally
|
||||||
-> grammar match (yes/no/one..four/approve/deny/send/type/mode/switch/cancel)
|
-> grammar match (yes/no/one..four/approve/deny/send/type/mode/set/target/cancel)
|
||||||
-> resolve target session (~/.claude-active)
|
-> resolve target session (~/.claude-active)
|
||||||
-> tmux send-keys -t <session> "<keys>"
|
-> tmux send-keys -t <session> "<keys>"
|
||||||
```
|
```
|
||||||
@ -74,7 +74,9 @@ claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes)
|
|||||||
claudedo start --skip-audio-check # skip the pre-listen mic check
|
claudedo start --skip-audio-check # skip the pre-listen mic check
|
||||||
claudedo status # running? mode? target session?
|
claudedo status # running? mode? target session?
|
||||||
claudedo stop # stop a running daemon
|
claudedo stop # stop a running daemon
|
||||||
claudedo switch <name> # retarget to claude-<name>
|
claudedo set <name> # set the sticky target -> claude-<name> (alias: switch)
|
||||||
|
claudedo unset # clear the sticky target
|
||||||
|
claudedo list # list running claude-* sessions
|
||||||
claudedo test-audio # verify the mic capture path
|
claudedo test-audio # verify the mic capture path
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -107,21 +109,35 @@ Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**.
|
|||||||
| `send` / `enter` | submit (Enter) |
|
| `send` / `enter` | submit (Enter) |
|
||||||
| `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") |
|
| `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") |
|
||||||
| `mode ptt` / `mode listen` | switch input mode |
|
| `mode ptt` / `mode listen` | switch input mode |
|
||||||
| `switch <name>` / `target <name>` | retarget to `claude-<name>` |
|
| `set <name>` (alias `sticky`/`switch`) | set the **sticky** target → `claude-<name>` (persists) |
|
||||||
|
| `target <name> <command>` | **one-shot** override: run that command on `claude-<name>` for this utterance only; sticky default unchanged |
|
||||||
|
| `unset` (alias `unsticky`) | clear the sticky target |
|
||||||
|
| `list` | list running `claude-*` sessions to the daemon console |
|
||||||
| `cancel` / `escape` | back out of a prompt |
|
| `cancel` / `escape` | back out of a prompt |
|
||||||
|
|
||||||
|
Optional filler (`select` / `use` / `choose`) may precede any command and is ignored:
|
||||||
|
`select yes` and `use yes` behave like `yes`. (`select 1` is still the select command.)
|
||||||
|
|
||||||
|
When no sticky target is set, a bare command auto-targets the **only** running
|
||||||
|
`claude-*` session; if several are running it does nothing and asks you to `set` one.
|
||||||
|
|
||||||
Number words are normalized to digits before matching ("one"/"won" → 1).
|
Number words are normalized to digits before matching ("one"/"won" → 1).
|
||||||
|
|
||||||
## Targeting
|
## Targeting
|
||||||
|
|
||||||
`~/.claude-active` holds the target session name (e.g. `claude-rethink-public`). The
|
`~/.claude-active` holds the **sticky** target session name (e.g.
|
||||||
**cc kit** writes this file when you attach, so the target is "the project you most
|
`claude-rethink-public`). The **cc kit** writes this file when you attach, and
|
||||||
recently attached to". `claudedo switch <name>` / `target <name>` overwrites it. If
|
`claudedo set <name>` (alias `sticky`/`switch`) overwrites it; `unset` clears it.
|
||||||
the file is missing or the session no longer exists, `claudedo` injects nothing and
|
A `target <name>` voice command is a **one-shot** that does NOT touch the sticky
|
||||||
logs a warning (it never guesses a target).
|
default — it routes a single command and the next bare command reverts to sticky.
|
||||||
|
|
||||||
|
Resolution order (one place — `target.resolve()`): one-shot if present →
|
||||||
|
sticky if set and the session exists → else the only running `claude-*` session →
|
||||||
|
else (zero or several) do nothing and say so. It never guesses, and never injects
|
||||||
|
into a nonexistent session.
|
||||||
|
|
||||||
Every name maps to `claude-<name>` through one helper (`target.session_name()`), and
|
Every name maps to `claude-<name>` through one helper (`target.session_name()`), and
|
||||||
the cc kit mirrors it exactly — so `cc libs` (shell) and `target libs` (voice) refer
|
the cc kit mirrors it exactly — so `cc libs` (shell) and `set libs` (voice) refer
|
||||||
to the same session `claude-libs`. The name is your **stable, speakable handle**:
|
to the same session `claude-libs`. The name is your **stable, speakable handle**:
|
||||||
because the kit forces an explicit name (no basename guessing), you always know the
|
because the kit forces an explicit name (no basename guessing), you always know the
|
||||||
exact word to say.
|
exact word to say.
|
||||||
|
|||||||
@ -51,3 +51,7 @@ max_utterance = 15.0
|
|||||||
type_autosend = false
|
type_autosend = false
|
||||||
# fuzzy match ratio (0..1) required to accept a wake phrase / command token.
|
# fuzzy match ratio (0..1) required to accept a wake phrase / command token.
|
||||||
match_threshold = 0.8
|
match_threshold = 0.8
|
||||||
|
# optional filler words that may precede a command and are ignored for matching:
|
||||||
|
# "select yes" / "use yes" behave like "yes". (a filler word followed by a digit is
|
||||||
|
# the select command, e.g. "select 1", and is not dropped.)
|
||||||
|
filler_words = ["select", "use", "choose"]
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "claudedo"
|
name = "claudedo"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
|
description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
|
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.1"
|
||||||
|
|||||||
@ -185,9 +185,26 @@ def cmd_install(_args: argparse.Namespace) -> int:
|
|||||||
return subprocess.call(["bash", str(script)])
|
return subprocess.call(["bash", str(script)])
|
||||||
|
|
||||||
|
|
||||||
def cmd_switch(args: argparse.Namespace) -> int:
|
def cmd_set(args: argparse.Namespace) -> int:
|
||||||
session = target.set_target(args.name)
|
session = target.set_target(args.name)
|
||||||
print(f"target -> {session}")
|
print(f"sticky target -> {session}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_unset(_args: argparse.Namespace) -> int:
|
||||||
|
target.unset_target()
|
||||||
|
print("sticky target cleared")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(_args: argparse.Namespace) -> int:
|
||||||
|
sessions = target.list_sessions()
|
||||||
|
if not sessions:
|
||||||
|
print("no claude sessions running")
|
||||||
|
return 1
|
||||||
|
active = target.read_active()
|
||||||
|
for s in sessions:
|
||||||
|
print(f"{'* ' if s == active else ' '}{s}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@ -208,10 +225,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sub.add_parser("status", help="show daemon status").set_defaults(func=cmd_status)
|
sub.add_parser("status", help="show daemon status").set_defaults(func=cmd_status)
|
||||||
sub.add_parser("test-audio", help="verify the mic capture path").set_defaults(func=cmd_test_audio)
|
sub.add_parser("test-audio", help="verify the mic capture path").set_defaults(func=cmd_test_audio)
|
||||||
sub.add_parser("install", help="re-run the bootstrap (install.sh)").set_defaults(func=cmd_install)
|
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)
|
||||||
|
|
||||||
sw = sub.add_parser("switch", help="set the active target session")
|
for verb in ("set", "switch"):
|
||||||
sw.add_argument("name", help="project short-name (claude- prefix optional)")
|
sp_set = sub.add_parser(verb, help="set the sticky target session")
|
||||||
sw.set_defaults(func=cmd_switch)
|
sp_set.add_argument("name", help="project short-name (claude- prefix optional)")
|
||||||
|
sp_set.set_defaults(func=cmd_set)
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,7 @@ class Config:
|
|||||||
max_utterance: float
|
max_utterance: float
|
||||||
type_autosend: bool
|
type_autosend: bool
|
||||||
match_threshold: float
|
match_threshold: float
|
||||||
|
filler_words: tuple[str, ...]
|
||||||
source_path: Path | None = field(default=None)
|
source_path: Path | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
@ -115,6 +116,8 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
|||||||
max_utterance=float(_require(raw, "audio", "max_utterance", (int, float), 15.0)),
|
max_utterance=float(_require(raw, "audio", "max_utterance", (int, float), 15.0)),
|
||||||
type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)),
|
type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)),
|
||||||
match_threshold=float(_require(raw, "behavior", "match_threshold", (int, float), 0.8)),
|
match_threshold=float(_require(raw, "behavior", "match_threshold", (int, float), 0.8)),
|
||||||
|
filler_words=tuple(_require(raw, "behavior", "filler_words", (list,),
|
||||||
|
["select", "use", "choose"])),
|
||||||
source_path=path,
|
source_path=path,
|
||||||
)
|
)
|
||||||
if not 0.0 < cfg.match_threshold <= 1.0:
|
if not 0.0 < cfg.match_threshold <= 1.0:
|
||||||
|
|||||||
@ -160,10 +160,12 @@ class Daemon:
|
|||||||
def _handle(self, transcript: str) -> None:
|
def _handle(self, transcript: str) -> None:
|
||||||
cfg = self.config
|
cfg = self.config
|
||||||
require_wake = self.mode == "listen"
|
require_wake = self.mode == "listen"
|
||||||
action = grammar.parse(transcript, cfg.wake_phrases, cfg.match_threshold, require_wake)
|
parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.match_threshold, require_wake,
|
||||||
if action is None:
|
filler=cfg.filler_words)
|
||||||
|
if parsed is None or parsed.action is None:
|
||||||
self._emit(f'heard: "{transcript}" -> no command matched')
|
self._emit(f'heard: "{transcript}" -> no command matched')
|
||||||
return
|
return
|
||||||
|
action = parsed.action
|
||||||
|
|
||||||
if action.name == "mode":
|
if action.name == "mode":
|
||||||
new_mode = str(action.arg)
|
new_mode = str(action.arg)
|
||||||
@ -172,24 +174,33 @@ class Daemon:
|
|||||||
self._emit(f"mode -> {new_mode}")
|
self._emit(f"mode -> {new_mode}")
|
||||||
self._refresh_state()
|
self._refresh_state()
|
||||||
return
|
return
|
||||||
if action.name == "switch":
|
if action.name == "set":
|
||||||
session = target.set_target(str(action.arg))
|
session = target.set_target(str(action.arg))
|
||||||
self._emit(f"target -> {session}")
|
self._emit(f"set sticky -> {session}")
|
||||||
self._refresh_state()
|
self._refresh_state()
|
||||||
return
|
return
|
||||||
|
if action.name == "unset":
|
||||||
session = target.resolve_target()
|
target.unset_target()
|
||||||
if session is None:
|
self._emit("unset (cleared)")
|
||||||
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} '
|
self._refresh_state()
|
||||||
f'-> ERROR no target session (did nothing)')
|
|
||||||
return
|
return
|
||||||
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} -> target {session}')
|
if action.name == "list":
|
||||||
|
sessions = target.list_sessions()
|
||||||
|
self._emit("list -> " + (", ".join(sessions) if sessions else "(none running)"))
|
||||||
|
return
|
||||||
|
|
||||||
|
session, reason = target.resolve(parsed.one_shot)
|
||||||
|
if session is None:
|
||||||
|
self._emit(f'heard: "{transcript}" -> {reason} -> matched {self._describe(action)} '
|
||||||
|
f'-> did nothing')
|
||||||
|
return
|
||||||
|
prefix = f'heard: "{transcript}" -> {reason} -> matched {self._describe(action)}'
|
||||||
if action.name == "type" and not cfg.type_autosend:
|
if action.name == "type" and not cfg.type_autosend:
|
||||||
inject.send_literal(session, str(action.arg))
|
inject.send_literal(session, str(action.arg))
|
||||||
self._emit(f"injected: literal {str(action.arg)!r} -> {session}")
|
self._emit(f"{prefix} -> injected literal {str(action.arg)!r} -> {session}")
|
||||||
return
|
return
|
||||||
inject.perform(session, action)
|
inject.perform(session, action)
|
||||||
self._emit(f"injected: {self._describe(action)} -> {session}")
|
self._emit(f"{prefix} -> injected {self._describe(action)} -> {session}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _describe(action) -> str:
|
def _describe(action) -> str:
|
||||||
|
|||||||
@ -27,20 +27,40 @@ _NUMBER_WORDS = {
|
|||||||
|
|
||||||
_INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4}
|
_INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4}
|
||||||
|
|
||||||
|
_STICKY_VERBS = ("set", "sticky", "switch")
|
||||||
|
_ONESHOT_VERBS = ("target",)
|
||||||
|
_UNSET_VERBS = ("unset", "unsticky")
|
||||||
|
_LIST_VERBS = ("list", "sessions")
|
||||||
|
_SELECT_VERBS = ("select", "option", "choose", "number")
|
||||||
|
DEFAULT_FILLER = ("select", "use", "choose")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Action:
|
class Action:
|
||||||
"""a matched command: a name plus an optional argument.
|
"""a matched command: a name plus an optional argument.
|
||||||
|
|
||||||
names: yes, no, select, approve, deny, submit, type, mode, switch, cancel.
|
names: yes, no, select, approve, deny, submit, type, cancel, mode, set, unset,
|
||||||
arg carries the select index (int), the literal text for ``type``, the mode for
|
list. arg carries the select index (int), the literal text for ``type``, the mode
|
||||||
``mode``, or the session short-name for ``switch``.
|
for ``mode``, or the session short-name for ``set``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
arg: object = None
|
arg: object = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedCommand:
|
||||||
|
"""a fully parsed utterance: an optional one-shot target plus the command action.
|
||||||
|
|
||||||
|
one_shot is the session short-name from a leading ``target <name>`` (this command
|
||||||
|
only; does not change the sticky default), or None. action is the command to run,
|
||||||
|
or None if nothing matched after the wake phrase / one-shot / filler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
one_shot: str | None
|
||||||
|
action: Action | None
|
||||||
|
|
||||||
|
|
||||||
def normalize(text: str) -> str:
|
def normalize(text: str) -> str:
|
||||||
"""lowercase, strip punctuation, collapse whitespace, map number words to digits"""
|
"""lowercase, strip punctuation, collapse whitespace, map number words to digits"""
|
||||||
text = text.lower().strip()
|
text = text.lower().strip()
|
||||||
@ -104,7 +124,12 @@ def _fuzzy_in(token: str, options: tuple[str, ...], threshold: float) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def match_command(remainder: str, threshold: float) -> Action | None:
|
def match_command(remainder: str, threshold: float) -> Action | None:
|
||||||
"""map a normalized command remainder to an Action, or None if unrecognized"""
|
"""map a normalized command remainder to an Action, or None if unrecognized.
|
||||||
|
|
||||||
|
expects the one-shot target and any leading filler to have been stripped already
|
||||||
|
(see parse). a leading ``select``/``option``/etc. is only treated as the select
|
||||||
|
command when followed by a digit; otherwise it is filler handled upstream.
|
||||||
|
"""
|
||||||
remainder = remainder.strip()
|
remainder = remainder.strip()
|
||||||
if not remainder:
|
if not remainder:
|
||||||
return None
|
return None
|
||||||
@ -125,12 +150,11 @@ def match_command(remainder: str, threshold: float) -> Action | None:
|
|||||||
return Action("deny")
|
return Action("deny")
|
||||||
if _fuzzy_in(head, ("send", "enter", "submit"), threshold):
|
if _fuzzy_in(head, ("send", "enter", "submit"), threshold):
|
||||||
return Action("submit")
|
return Action("submit")
|
||||||
if _fuzzy_in(head, ("cancel", "escape", "stop"), threshold):
|
if _fuzzy_in(head, ("cancel", "escape"), threshold):
|
||||||
return Action("cancel")
|
return Action("cancel")
|
||||||
|
|
||||||
if _fuzzy_in(head, ("select", "option", "choose", "number"), threshold) and rest:
|
if _fuzzy_in(head, _SELECT_VERBS, threshold) and rest and rest[0] in _INDEX_WORDS:
|
||||||
if rest[0] in _INDEX_WORDS:
|
return Action("select", _INDEX_WORDS[rest[0]])
|
||||||
return Action("select", _INDEX_WORDS[rest[0]])
|
|
||||||
|
|
||||||
if _fuzzy_in(head, ("type", "dictate", "write"), threshold):
|
if _fuzzy_in(head, ("type", "dictate", "write"), threshold):
|
||||||
text = " ".join(rest).strip()
|
text = " ".join(rest).strip()
|
||||||
@ -143,17 +167,48 @@ def match_command(remainder: str, threshold: float) -> Action | None:
|
|||||||
return Action("mode", "listen")
|
return Action("mode", "listen")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if _fuzzy_in(head, ("switch", "target"), threshold) and rest:
|
if _fuzzy_in(head, _STICKY_VERBS, threshold) and rest:
|
||||||
name = "".join(rest)
|
name = "".join(rest)
|
||||||
return Action("switch", name) if name else None
|
return Action("set", name) if name else None
|
||||||
|
if _fuzzy_in(head, _UNSET_VERBS, threshold) and not rest:
|
||||||
|
return Action("unset")
|
||||||
|
if _fuzzy_in(head, _LIST_VERBS, threshold):
|
||||||
|
return Action("list")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_filler(tokens: list[str], filler: tuple[str, ...], threshold: float) -> list[str]:
|
||||||
|
"""drop leading optional filler words (e.g. select/use/choose) before a command.
|
||||||
|
|
||||||
|
a filler word that is followed by a digit is NOT dropped — that is the select
|
||||||
|
command (``select 1``), handled by match_command.
|
||||||
|
"""
|
||||||
|
while tokens and _fuzzy_in(tokens[0], filler, threshold):
|
||||||
|
if len(tokens) > 1 and tokens[1] in _INDEX_WORDS:
|
||||||
|
break
|
||||||
|
tokens = tokens[1:]
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
def parse(transcript: str, wake_phrases: list[str], threshold: float,
|
def parse(transcript: str, wake_phrases: list[str], threshold: float,
|
||||||
require_wake: bool) -> Action | None:
|
require_wake: bool, filler: tuple[str, ...] = DEFAULT_FILLER) -> ParsedCommand | None:
|
||||||
"""full parse: wake gate then command match. None means discard"""
|
"""full parse: wake gate -> optional one-shot target -> filler -> command.
|
||||||
|
|
||||||
|
returns a ParsedCommand (one_shot, action), or None if the wake gate dropped the
|
||||||
|
utterance (listen mode, no wake phrase). a ParsedCommand with action=None means a
|
||||||
|
wake phrase was present but no command matched.
|
||||||
|
"""
|
||||||
remainder = strip_wake(transcript, wake_phrases, threshold, require_wake)
|
remainder = strip_wake(transcript, wake_phrases, threshold, require_wake)
|
||||||
if remainder is None:
|
if remainder is None:
|
||||||
return None
|
return None
|
||||||
return match_command(remainder, threshold)
|
|
||||||
|
tokens = remainder.split(" ") if remainder else []
|
||||||
|
one_shot: str | None = None
|
||||||
|
if tokens and _fuzzy_in(tokens[0], _ONESHOT_VERBS, threshold) and len(tokens) >= 2:
|
||||||
|
one_shot = tokens[1]
|
||||||
|
tokens = tokens[2:]
|
||||||
|
|
||||||
|
tokens = _strip_filler(tokens, filler, threshold)
|
||||||
|
action = match_command(" ".join(tokens), threshold)
|
||||||
|
return ParsedCommand(one_shot=one_shot, action=action)
|
||||||
|
|||||||
@ -18,8 +18,9 @@ def session_name(name: str) -> str:
|
|||||||
|
|
||||||
single source of truth for the name->session mapping. the shell cc kit
|
single source of truth for the name->session mapping. the shell cc kit
|
||||||
(~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice
|
(~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice
|
||||||
commands ``switch libs`` / ``target libs`` all resolve to ``claude-libs``. an
|
commands ``set libs`` (sticky) / ``target libs`` (one-shot) all resolve to
|
||||||
already-prefixed name is returned unchanged so callers can pass either form.
|
``claude-libs``. an already-prefixed name is returned unchanged so callers can
|
||||||
|
pass either form.
|
||||||
"""
|
"""
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}"
|
return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}"
|
||||||
@ -38,18 +39,28 @@ def read_active() -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def write_active(name: str) -> None:
|
def write_active(name: str) -> None:
|
||||||
"""overwrite ~/.claude-active with a session name (used by ``switch``)"""
|
"""overwrite ~/.claude-active with a session name (the sticky default)"""
|
||||||
ACTIVE_FILE.write_text(name + "\n", encoding="utf-8")
|
ACTIVE_FILE.write_text(name + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def set_target(name: str) -> str:
|
def set_target(name: str) -> str:
|
||||||
"""map a project short-name via session_name() and persist it. returns the
|
"""map a project short-name via session_name() and persist it as the sticky
|
||||||
resolved session name."""
|
default. returns the resolved session name."""
|
||||||
session = session_name(name)
|
session = session_name(name)
|
||||||
write_active(session)
|
write_active(session)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def unset_target() -> None:
|
||||||
|
"""clear the sticky default (empty/remove ~/.claude-active)"""
|
||||||
|
try:
|
||||||
|
ACTIVE_FILE.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("could not clear %s: %s", ACTIVE_FILE, exc)
|
||||||
|
|
||||||
|
|
||||||
def session_exists(name: str) -> bool:
|
def session_exists(name: str) -> bool:
|
||||||
"""true if a tmux session with this name currently exists"""
|
"""true if a tmux session with this name currently exists"""
|
||||||
if not name:
|
if not name:
|
||||||
@ -62,24 +73,47 @@ def session_exists(name: str) -> bool:
|
|||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def resolve_target() -> str | None:
|
def list_sessions() -> list[str]:
|
||||||
"""return the active session name only if it exists; else log and return None.
|
"""return the names of all running claude-* tmux sessions (sorted)"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||||
|
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))
|
||||||
|
|
||||||
never guesses a target: on a missing/empty ~/.claude-active or a stale session
|
|
||||||
name, this logs a clear warning and returns None so the caller injects nothing.
|
|
||||||
|
|
||||||
TODO: most-recently-active targeting (preferred over attached). today the target
|
def resolve(one_shot: str | None = None) -> tuple[str | None, str]:
|
||||||
is the project most recently ATTACHED to (the cc kit writes ~/.claude-active on
|
"""resolve the destination session and a short reason describing the choice.
|
||||||
attach); upgrade to the session claude most recently asked a question in, via
|
|
||||||
tmux session_activity timestamps (list-sessions -F '#{session_name}
|
single source of truth for targeting, used by both the voice and CLI paths.
|
||||||
#{session_activity}', pick the highest-activity claude-* session) or by scraping
|
returns (session_or_None, reason). a None session means inject nothing; the
|
||||||
panes (capture-pane) for a waiting-prompt UI.
|
reason explains why (for the daemon console / CLI message). resolution order:
|
||||||
|
|
||||||
|
1. one-shot present -> claude-<name> for THIS command only; never falls through
|
||||||
|
to a different session if it doesn't exist (explicit beats convenience).
|
||||||
|
2. sticky set + exists -> use it.
|
||||||
|
3. nothing sticky, exactly one claude-* session -> auto-use it.
|
||||||
|
4. nothing sticky, multiple sessions -> ambiguous, do nothing.
|
||||||
|
5. nothing sticky, zero sessions -> do nothing.
|
||||||
"""
|
"""
|
||||||
name = read_active()
|
if one_shot is not None:
|
||||||
if not name:
|
session = session_name(one_shot)
|
||||||
log.warning("no active session set (%s missing/empty) — run `cc` to attach", ACTIVE_FILE)
|
if session_exists(session):
|
||||||
return None
|
return session, f"one-shot {session}"
|
||||||
if not session_exists(name):
|
return None, f"one-shot {session} does not exist (did nothing)"
|
||||||
log.warning("target session %r no longer exists — skipping injection", name)
|
|
||||||
return None
|
sticky = read_active()
|
||||||
return name
|
if sticky:
|
||||||
|
if session_exists(sticky):
|
||||||
|
return sticky, f"sticky {sticky}"
|
||||||
|
return None, f"sticky {sticky} no longer exists (set one)"
|
||||||
|
|
||||||
|
sessions = list_sessions()
|
||||||
|
if len(sessions) == 1:
|
||||||
|
return sessions[0], f"auto-target {sessions[0]} (only session)"
|
||||||
|
if len(sessions) > 1:
|
||||||
|
return None, f"no target set, {len(sessions)} sessions (set one)"
|
||||||
|
return None, "no claude sessions"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user