Windows toasts cannot call back into the daemon, so the Cancel button opens
a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon
registers the talk: URL protocol for the current user (HKCU, no admin) and
points it at `talk uri "%1"`; Windows starts that on the click, and `talk
uri` posts to the new endpoint POST /cancel-item, which cancels only the
announcement with that id (queue.CancelItem). The toast is tagged and
removed again when the announcement ends. For now the toast is shown under
Windows PowerShell's app identity, which unpackaged programs can use
without a Start Menu shortcut.
Verified on the Windows 11 VM: registry entry written by the daemon,
Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops
the playing announcement within two seconds, no toast errors in the log.
Tests: `talk uri` against a real handler on a loopback port (playing vs
waiting id, dead daemon, talk:test, garbage links), POST /cancel-item
process test (only the playing item, waiting one plays next, 400 without
id).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every playing announcement now shows a desktop notification with its text
and, on Linux, a Cancel button (Abbrechen for German) that stops exactly
that announcement; the notification is closed when the announcement ends.
This brings back what the old talk.sh had with notify-send --action, as a
default: a long announcement can be cut short without a terminal.
Implementation: the freedesktop Notifications service on the session bus
via godbus (pure Go): one connection per process, Notify with the action
list, ActionInvoked dispatched to the announcement, CloseNotification at
the end, expire_timeout 0 so the button stays for the whole announcement.
Without a session bus the notify-send fallback shows a plain notification;
macOS and Windows show one without a button for now. queue.CancelItem(id)
cancels only the playing item. Config [notify] on_speak (default true, env
TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a
click; `talk doctor` names the service and whether it supports the button.
Config hygiene found on the way: a key written below a [table] header
belongs to that table in TOML (lang under [voice] was silently ignored).
config.Load now collects unknown keys (undecoded ones and non-language
keys under [voice.<provider>]) and `talk doctor` warns with the hint.
Tests: process test on a private dbus-daemon with a fake
org.freedesktop.Notifications service (Notify payload, cancel click aborts
the synthesis, CloseNotification), queue.CancelItem, config keys. All test
binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever
pops a notification on the developer's desktop. flake vendorHash updated
for the new dependency.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit
Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je
OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der
Degraded Mode kannte weder Rate-Limit noch Ruhezeiten.
Jetzt:
- internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage);
Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite
Zusammensetzung der Kette mehr.
- internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per
Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei
daemon.pid, Shutdown-Trigger.
- `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter
Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg
und die PID-Datei gelöscht ist (Prozess wirklich beendet).
- Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst
(daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein
paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt
den Daemon des anderen — kein Timeout-Warten.
- Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren
Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis +
Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst
nicht; `talk status` zeigt „läuft nicht" (Exit 1).
- cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt
neu.
Tests: Harness baut nur noch talk und startet `talk daemon run`;
TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test
hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart
beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter
start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile
aller sechs Targets mit CGO_ENABLED=0 verifiziert.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CORS: Preflight 204+Header von gelisteter Origin, kein ACAO für ungeliste/
leere Liste, Wildcard, nur-/speak, cors-Feld in /status. MCP: kompletter
Handshake als literal JSON-RPC über HTTP, tools/call enqueue sichtbar am
Fake-Provider, muted/rate_limit/quiet_hours als isError:false-Ergebnisse,
Fehlerpfade (-32700/-32600/-32601/-32602), GET /mcp 405.
Kurzes Fenster (1 s): fünf Ansagen → genau eine spielt sofort, low
verfällt beim Eintritt (202 + CLI 'discarded: rate_limit'), normal parkt
(sichtbar in queue_ids/next_slot) und spielt frühestens zum Slot
(Timing-am-Fake gemessen). High-Burst an/aus; now bricht durch Rate-Limit
UND Ruhezeit; Ruhezeit-Fenster künstlich um die lokale Jetztzeit gelegt
→ discard (auch high) und toast (Protokoll-Zeile, kein Provider-Kontakt);
/status- und talk-status-Felder im Default. Harness-Basis: TALK_RATE_LIMIT=0s
— die Task-04-Semantik-Tests erwarten dichtes Durchspielen, Gesetz-Tests
überschreiben bewusst.