This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Repository navigation
Merge/upstream main - #1
Merged
Merged
Conversation
…#596) * fix(health-check): catch dead bridge log paths + dead inodes Two bugs found while diagnosing why Mini's discord-bridge log was 36h stale yet health-check stayed "ok": 1. Log staleness check used `src/<name>.log` but startup.sh writes to `logs/<name>.log` (changed 2026-04). The check has been a silent no-op for ~6 weeks. Prefer logs/ first, fall back to src/. 2. New Check 5: detect dead log inode. The bridge's stdout/stderr FDs can outlive the file they were originally pointed at if the log gets renamed-then-deleted (caught 2026-05-05 with discord-bridge FDs → /discord-bridge.log.bak which had been unlinked, so all logging silently went to /dev/null while heartbeat + log mtime stayed fresh). lsof shows the process's FD targets; if the path doesn't exist on disk, flag warn with restart guidance. Placed Check 5 AFTER the heartbeat check so a fresh heartbeat doesn't silently override the dead-inode condition. Verified locally: discord-bridge now shows ⚠ discord-bridge warn running but log inode dead (/discord-bridge.log.bak unlinked) — restart for clean logging Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): apply MacBook's PR sonichi#596 review nits 1. Path-with-spaces robustness: parse NAME column as ' '.join(parts[8:]) instead of parts[-1]. lsof's NAME column starts at index 8 on macOS; joining the tail tokens preserves paths that contain spaces. 2. Restart guidance specificity: include the exact `launchctl kickstart` command (with `name` interpolated for the right service label), plus a Mini-specific note for nohup+disown bridges per feedback_mini_bridges_no_launchd.md. Verified locally: discord-bridge now reports the actionable command: ⚠ running but log inode dead (/discord-bridge.log.bak unlinked) — restart with: launchctl kickstart -k gui/$(id -u)/com.sutando.discord-bridge (or nohup+disown on Mini) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… clipboard (sonichi#594) Three shell injection vectors in osascript/execSync calls: 1. type_text: single-quote in user text breaks out of the single-quoted shell argument. Text like test'whoami' executes whoami via osascript. Fix: add .replace(/'/g, "'\\''") to the escaping chain, matching the existing pattern in switchAppTool. 2. press_key: same single-quote injection for unknown keys that fall through to the keystroke branch. Fix: same three-step escaping chain. 3. clipboard write: echo $(JSON.stringify(text)) | pbcopy wraps text in double-quotes but JSON.stringify does not prevent $() substitution. Text like $(whoami) gets executed by bash inside double quotes. Fix: write to temp file and pipe to pbcopy, eliminating shell interpolation entirely.
…#593) PR sonichi#579 bumped the dependency reference but left the resolved field at ee58489a, so `npm install` reused the cached older bodhi tarball. Voice agents started after sonichi#579 still loaded the pre-fix code (no silent-replay instructions on either reconnect path). Fix: rerun `npm install github:sonichi/bodhi_realtime_agent#ffec0cd` to update the resolved entry. Now next voice-agent restart actually picks up bodhi #16 (silent-replay text in both reconnect paths) and #17 (reconnect promise deadline). Co-authored-by: Vasiliy Radostev <vasiliyr@Vasiliys-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…>/ (sonichi#591) Mini flagged on the sonichi#589 cold review: the timeout path moved task files to tasks/processed/ while every other "task is done" path uses archiveFile() → tasks/archive/<YYYY-MM>/. Two destinations means the learn-collector / summarizer would have to scan both. Replaced the inline mkdir+renameSync with a single archiveFile() call. Same shape as the result-delivery + deduped + voice-offline paths. _isVoiceTask() already checks both processed/ AND archive/ when looking up source channel, so no breakage for tasks already in processed/ from before this PR.
* feat(context-drop): emit `type: files` task when multiple Finder items selected
`getFinderSelection()` and `context-drop.sh` both hardcoded `item 1 of sel`,
silently dropping multi-selections to first file. Iterate the selection,
emit a single task file with `type: files` + `paths:` block when count > 1;
keep the existing `type: file` + `path:` shape for the single-file case.
Notification text reflects the count ("3 files dropped" vs filename).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(context-drop): JSON-array paths for unambiguous parsing (per Mini's review)
Per Mini's feedback on PR sonichi#595: emit `paths: ["/a","/b"]` as JSON array on
one line instead of YAML-ish multi-line block. Reasons:
- No YAML lib dep — Python/TS json.* in stdlib
- Path escaping: "file with: colon.txt" parses cleanly
- Single-line parse, no block-scalar state
Body trailer keeps the human-readable ` - path` list for at-a-glance.
Swift uses JSONSerialization; bash uses python3 json.dumps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ichi#597) State leak: pending_replies entries persist forever when the agent silently dedupes / crashes / ignores a task without writing a result file. Pop only fires when results/<task_id>.txt appears (line 921). Caught 2026-05-05 with 375 entries in state/discord-pending-replies.json accumulating since 2026-04-12. Median age 4 days, 124 entries >7d old. Fix: aging pass at startup. Walk loaded entries, drop any whose task_id timestamp is more than 7 days old. Dry-run on current state shows 124/375 would age out, leaving 251 fresh entries. Why startup-only: aging during the main poll loop adds work to the hot path. Once-per-session is enough — a few extra hours of staleness isn't catastrophic. Why 7 days: covers the rare case where a task is replied to days late (owner return after vacation). Comfortably longer than any legitimate delay. Test plan - [x] Dry-run age-out on live state file: 124 would-be-aged-out - [ ] Restart bridge, verify "[recovery] aged out N pending_replies > 7d" log line + state file shrinks to expected count Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) * fix(self-diagnose): resolve REPO via SUTANDO_ROOT/PWD before script-walk The dirname-walk `$(cd "$(dirname "$0")/../../.." && pwd)` fails when the skill fires via its userSettings hardlink at ~/.claude/skills/self-diagnose/scripts/gather.sh — $0 resolves there and walking up 3 lands at ~/.claude/ instead of the Sutando workspace. Caught 2026-05-05 when /self-diagnose ran on Mini and produced empty git-log.txt / git-status.txt / build_log-tail.md / health.txt because ~/.claude/ has none of those files. Skill silently broken since the hard-linked invocation pattern was added. Fix: prefer $SUTANDO_ROOT env var (with build_log.md sanity check), then fall back to $PWD if it looks like a Sutando workspace, else the existing dirname-walk for backward compat. Verified: from cwd=/Users/wangchi/Desktop/sutando, gather.sh now populates git-log (379 bytes), build_log-tail (20KB), cold-review-log (34KB), discord-bridge-recent (17KB), health (2KB). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(self-diagnose): export SUTANDO_ROOT + smarter REPO walk Per Chi's feedback on the prior version: the SUTANDO_ROOT env-var fallback only helps if the var is actually set, and "hardcode it in .env" was the wrong answer. Two changes (stack A + B from the design discussion): A) src/startup.sh: derive REPO from BASH_SOURCE position (already done for $REPO at line 7) and `export SUTANDO_ROOT="$REPO"`. Works for any install location, no hardcode. Child processes (Claude session, skills, gather scripts) inherit the var. B) skills/self-diagnose/scripts/gather.sh: replace the 3-level dirname-walk with a 5-level walk that stops at the first dir containing build_log.md. Catches invocation via the workspace skill path even when no env var is set. Last-resort dirname-walk preserved for backward compat. Verified all 4 invocation cases: - cwd=workspace, no env → PWD branch, REPO correct ✓ - cwd=/tmp, workspace-path → walk-up branch, REPO correct ✓ - cwd=/tmp, userSettings link, no env → still broken (no signal at all) - cwd=/tmp, userSettings link, env set → env branch, REPO correct ✓ The only remaining broken case (userSettings link invoked from outside a workspace with no env) requires an actual config file or hardcoded search paths to fix; A+B closes the realistic invocation scenarios. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…th (sonichi#599) Per MacBook's PR sonichi#597 review nit: rewrite at load_pending_replies_from_disk was not atomic (raw write_text), risking truncation if the process crashes mid-write at startup. The same shape existed in save_pending_replies(). Extracted `_atomic_write_pending_replies(data)` — tmp file + rename. Both call sites now use it. The atomicity fix lands together with the age-out in this same PR per "aligning both at once" suggestion in the review. Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ect (sonichi#601) Root cause of the 2026-05-05 friction 9h-delay: dm-fallback in discord-bridge shells to dm-result.py, which queries /sse-status for voiceConnected. web-client.ts tries voice-state.json first (authoritative), falls back to _voiceState (browser-SSE module var). On instances that have never had a client connect (e.g. Mac Mini, where voice routes to MacBook), voice-state.json doesn't exist — voice-agent only wrote it inside writeVoiceState() called from onConnected/onDisconnected. So voiceConnected silently fell through to the sticky _voiceState fallback, which has no TTL or liveness check. A morning-set sticky-true parked friction files for 9 hours until the var transitioned. Fix: call writeVoiceState(false) once right after the function is defined. The file is now always present + always reflects the latest known state. After a session starts/ends, normal connect/disconnect calls overwrite. After voice-agent restart, the file resets to false. This is fix A from notes/friction-9h-delay-investigation-2026-05-05.md. MacBook reviewed the design discussion, confirmed A as must-have, B (TTL on _voiceState) as soft nice-to-have to defer. Shipping A only. Test plan - [x] Syntax check (tsc --noEmit) - [ ] Restart voice-agent on Mini. Verify voice-state.json appears in cwd with {connected: false, ts: ...}. - [ ] Drop a marker friction-*.txt in results/. Verify dm-fallback delivers within ~30-90s instead of waiting on _voiceState transition. Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…op draft) (sonichi#600) * feat(sutando-app): add chip refresh timer (replaces /personal-reactive-loop draft) Folds the never-shipped /personal-reactive-loop sub-loop draft into Sutando.app's existing polling timer infrastructure. Chip refresh is mechanical (gh pr list, pending-questions.md first heading, recent results dir scan) — no LLM round-trip needed, so the natural home is the menu-bar app that already does watcher liveness. Per Chi's review 2026-05-05: "if it's only scripts, can it be merged with the sutando app? The app also ensures watcher alive." What lands: - 120s Timer in applicationDidFinishLaunching → refreshContextualChips() - One-shot dispatch at startup so chips aren't stale-from-yesterday - Atomic write via tmp + replaceItemAt - runShell() helper that explicitly sets PATH (apps launched via `open` inherit a minimal PATH that excludes /opt/homebrew) and drains both stdout + stderr pipes - gh path resolved by FileManager.fileExists vs hardcoding (Apple-Silicon /opt/homebrew/bin first, Intel /usr/local/bin fallback) - gh invoked with explicit --repo sonichi/sutando since the app's CWD isn't a git repo Verified: chip file shows 3 PR-review chips + 1 pending-question chip within seconds of app start. Deletes the draft `personal-reactive-loop` skill in the private memory repo (separate commit there). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(proactive-loop): remove step 11 (chip refresh) — moved to Sutando.app Per Mini's sonichi#600 review nit #3 + Chi's confirmation: after PR sonichi#600 lands, Sutando.app is the canonical writer for contextual-chips.json. Removing step 11 from the proactive-loop SKILL.md establishes the single-writer invariant immediately, before /build-loop is split out. Step 12 (heartbeat) renumbered to 11 with a note explaining where chip refresh moved + why future cases that need decision-state in chips should surface via a different file Sutando.app reads, not by competing as a writer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(sutando-app): drop --author sonichi from chip PR filter Per Mini's sonichi#600 review nit #1: the --author filter excluded community PRs (sonichi#594 Jason shell-injection, sonichi#593 Vasiliy bodhi lock) from the chip set. Both bots commit as sonichi so the filter caught fleet PRs but lost external contributions that are also worth triaging from the menu bar. Drop the filter; "Review PR #N" chips now include all open PRs (capped at 3 by the existing prefix(3)). Community PRs surface alongside fleet PRs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(menu-bar): render initial active-mode bullet + document ⌃S hotkey
Two related menu-bar UX gaps caught 2026-05-05:
1. **Mode dropdown bullet missing on Active when active is the default.**
`pollVoiceMode()` short-circuits via `voiceMode != newMode` to avoid
redundant updates, but the very FIRST tick after launch matches the
default `voiceMode = "active"` against the file's "active" sentinel
and bails — so `updateModeMenuItem()` never runs and the menu items
stay at their creation-time titles (" Mode: Active"), no `●`. Common
case: any restart that lands in active mode (the majority).
Fix: call `updateModeMenuItem()` once at end of
`applicationDidFinishLaunching` so the bullet renders immediately
whether or not pollVoiceMode subsequently fires an update.
2. **⌃S (drop_screenshot) hotkey was registered but undocumented in the
web UI status bar (`src/web-client.ts:646`) and the dashboard shortcut
block (`src/dashboard.py:315`).** Both surfaces listed ⌃C / ⌃V / ⌃M
only. Added the ⌃S kbd line to both.
Verified locally:
- Sutando.app rebuilt + relaunched: bullet now appears next to "Mode:
Active" immediately on launch.
- web-client + dashboard restarted: ⌃S kbd label rendered in both surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: replace static hotkey lists with single canonical table + pointers
Per Chi's review 2026-05-05: CLAUDE.md and README.md both duplicated the
hotkey list in 4 places combined, and ALL 4 spots stayed stale when ⌃S
was registered (drift caught when Chi noticed ⌃S missing from dashboard
+ web-client UI surfaces, fixed in PR sonichi#604's earlier commit).
Two design choices applied here:
1. **CLAUDE.md** is the agent-facing operations manual; the agent reads
it on every session. A static hotkey list there means stale answers
when the agent is asked "what hotkeys do I have". Replace the list
with a pointer to `~/.config/sutando/hotkeys.json` and `src/Sutando/
main.swift:944` (the registration). When the user asks, the agent
reads those sources directly — no drift surface.
2. **README.md** is public-facing. Keep one canonical hotkey table at
§Keyboard shortcuts (line 268), and change the 3 other mentions
(line 126 banner, line 193 services table, line 310 Accessibility
note) to reference the canonical table. Drift surface goes from 4
spots → 1 spot. Plus add ⌃S to the canonical table (was missing).
The structural fix (build script that generates a hotkeys-snippet.md
partial from hotkeys.json, included in CLAUDE.md and README.md via
comment markers) is deferred — Chi tagged it as the right long-term
answer when there's slack, but quick patch lands now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…contextual app routing (sonichi#605) Closes sonichi#560. Voice / text users can now direct `open_file` to a specific macOS app instead of always going through the file-type default. Resolves the "open this in Sublime Text", "open the SQLite db in TablePlus" flow. Resolution per issue: 1. Explicit `app` arg → `open -a <app> <path>` 2. No `app` → `open <path>` (macOS LaunchServices picks default) Contextual inference (rule 2 from the issue body — model passes `app` based on prior conversation, even without a literal "with" phrase) is the model's job. The tool description spells out when Gemini should populate `app`; the tool itself only honors what it's told. Argv-form preserved: `open -a "<app>" "<path>"` is invoked via `execFileSync` with both as separate argv entries — no shell interpolation, no command-line-injection risk (same CodeQL `js/command-line-injection` class as #27). Tests: 4 cases — schema shape (optional string, parses with and without `app`), description mentions the `app` trigger phrasing, File-not-found error surfaces correctly both with and without `app`. Did NOT add tests that actually open files (would mutate the test machine's app state). Verified: - 4/4 unit tests pass - `npx tsc --noEmit` clean Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Chi's "shall we add core CLI-related commands in sutando app" prompt 2026-05-05: anything mechanical that the user does in the terminal but doesn't need LLM judgment is a candidate for a menu item. This PR adds two: **Pause Loop (30 min)** and **Resume Loop**. Backed by `state/loop-paused-until.sentinel` which the proactive-loop's skip-conditions §(d) already checks (per `~/.claude/skills/proactive-loop/SKILL.md`). No code change to the loop needed — the sentinel mechanism already exists, this just gives it a UI. Sentinel format: ISO-8601 UTC expiry timestamp. The loop auto-skips step-6 work while now < expiry. Auto-expires so a forgotten pause re-enables itself in 30 min. Resume Loop is idempotent — no-op if sentinel doesn't exist. Restart Core was discussed but deferred — needs `scripts/start-cli.sh` extracted from `src/startup.sh` first to give the menu item something to invoke. Voice agent restart was explicitly excluded by Chi. Verified: app rebuilt + relaunched (PID 78953); menu items present; Pause writes a future-dated UTC ISO sentinel; Resume removes it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#607) * feat(health-check): add --emit-task flag to surface failures to the CLI Bridges the detection-vs-action gap caught 2026-05-05 (voice-transport 1011 sat broken 4h before Chi noticed): dashboard + morning-briefing already surface health failures, but no path drove the AGENT to act on them. Now health-check at any cron tick can produce a task file → watcher fires → CLI processes as owner-tier task → LLM judgment at the act step (restart, DM owner, or treat as transient). Per Mini's pivot review: - No auto-restart in Sutando.app — keeps LLM judgment in the CLI. - Hash dedup over the FULL active failure set (sorted member names), not member-by-member, so identical sets within cooldown suppress but set changes fire fresh. - 1h TTL cooldown per hash → persistent failures re-alert hourly (vs spamming every 30 min). - 24h history pruning to bound state-file size. Task body schema matches Discord-tier task files (id / timestamp / task / source: health-check / user_id: health-check / access_tier: owner) so the CLI's existing dispatch handles it without special-casing. State: `state/health-last-alerted.json` keyed by sha256(sorted-failure- set)[:16] → last-alerted-ms. Per-host (state/ stays per-node, only presenter-mode.sentinel is opt-in cross-node-synced). To enable on the existing every-30-min cron: edit ~/Library/LaunchAgents/com.sutando.health-check.plist ProgramArguments to add `--emit-task` after `--fix`. Manual step — not bundled here. Verified locally: syntax-clean (ast.parse), zero current failures so emit-task no-ops correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): include 'warn' status in --emit-task failure predicate Per Mini's PR sonichi#607 review: the original predicate excluded 'warn', so the dead-log-inode case (PR sonichi#596 reports as ⚠ warn — bridge running but logging to deleted file) wouldn't have triggered an emit-task. That's exactly the bug class this watchdog was supposed to catch. Adding 'warn' to the set so the failure predicate matches all statuses that indicate "service is up but has a real issue" or "service is broken" — both should reach the CLI for an action decision. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cli.sh (sonichi#608) * feat(sutando-app + scripts): Restart Core CLI menu + extracted start-cli.sh Per Chi 2026-05-05: "shall we add core CLI-related commands in sutando app" → "menu commands. no voice agent restart" → "restart core yes". PR sonichi#606 added Pause/Resume Loop. This adds Restart Core CLI, plus extracts the canonical launch command into `scripts/start-cli.sh` so both startup.sh and Sutando.app's menu invoke the same path (no duplicated flags drift surface). Three changes: 1. **`scripts/start-cli.sh`** (new, +82 LOC) — single source of truth for the sutando-core tmux launch. Args: - bare invocation: idempotent. Attach if running (interactive), else exit cleanly. Start if not running. - `--restart`: kill existing session first, then start fresh. Handles tmux auto-install, detached vs interactive launch, and the no-tmux fallback. 2. **`src/startup.sh`** — replaces the ~50-line inline tmux block at the end with a single `exec bash "$REPO/scripts/start-cli.sh"`. No behavior change for the user — startup.sh still ends by attaching to (or starting) sutando-core, just delegating now. 3. **`src/Sutando/main.swift`** — adds `Restart Core CLI` menu item in the Restart/Stop block. Spawns `bash scripts/start-cli.sh --restart` detached. Voice-agent restart explicitly excluded per Chi. Verified: bash -n syntax clean on start-cli.sh, swiftc -O clean. Did NOT relaunch Sutando.app or test --restart (running session would self-terminate mid-conversation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(restart-core): notify-on-failure + self-kill hazard doc + poll-not-sleep Per Mini's PR sonichi#608 review nits: 1. Silent-failure UX in restartCore() — was `try?` + null stderr = user gets the "Restarting..." notify but nothing follows on failure. Now: capture stderr, branch on terminationStatus, surface a notify either way ("Core restarted" on success, "Core restart failed (exit N): <stderr preview>" on failure). Also catches Process.run() throw so a missing scripts/start-cli.sh isn't silent. 2. Self-kill hazard doc — added a HAZARD block comment in BOTH main.swift restartCore() and scripts/start-cli.sh's --restart branch. Names the safe callers (menu, terminal, emit-task) and the unsafe one (agent self-invocation from inside sutando-core). 3. sleep 1 → poll loop — `for _ in 1..5; do pgrep ... || break; sleep 0.2; done` makes the post-kill wait robust on slow machines and snappy on fast ones, with the same ~1s ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi#609) Per Susan 2026-05-05: voice transcripts in conversation.log were getting truncated mid-sentence — visible especially with longer turns and CJK text where each char can be multi-byte at the layer above. The 200-char cap was aggressive and made the log unreadable for normal-length user/assistant exchanges. Bump to 2000 (matches Discord's per-message limit, so a single log line never exceeds what could legitimately appear in the conversation surface). Both call sites updated. Tunable via SUTANDO_LOG_LINE_MAX_CHARS env if a host wants tighter logs. Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(health-check): emit-task dedup invariants (a–f) Follow-up to PR sonichi#607's cold review nit. Adds 6-case regression test guarding the dedup logic + a tiny refactor to make the function testable. src/health-check.py: emit_task_for_failures() gains optional `state_file` and `tasks_dir` kwargs (default to the workspace paths it already used). Production behavior unchanged — tests inject temp dirs. tests/health-check-emit-task.test.py: new standalone test, follows the pattern of the other Python tests in this dir. a) empty failures → no task file b) same set within 1h → cooldown suppresses c) set changes → new task fires (different hash) d) 24h pruning bounds state file e) `warn` triggers a task (32efa4d followup motivating bug class) f) hash covers full sorted set, not first-member-only Manually negative-tested e: removing "warn" from the predicate makes case e fail with the expected message. Restored, all 6 pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nit: drop unused unittest.mock import (per sonichi#610 review) MacBook flagged at review: `import unittest.mock` was a leftover from an earlier draft that monkey-patched module state; the final version uses kwargs injection instead and never references mock. Drop it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(test): include tests/*.test.py in npm test The 4 existing Python tests in tests/ (discord-bridge-access-tier, discord-bridge-allowlist, discord-chunker, env-loader) are written and passing locally but were never executed in CI — `npm test` only invoked `tsx --test tests/*.test.ts`. Test rot in the dark. Adds: - `test:py` script that runs every `tests/*.test.py` in sequence, exiting on first failure. - `test` chains the existing tsx run with `&& npm run test:py`, so both local `npm test` and CI run all tests. CI workflow needs no change — it already invokes `npm test` and the `paths` filter already covers `tests/**` + `package.json`. Verified: `npm test` locally passes both the tsx tests and all 4 Python tests. Each script has been standalone-runnable since it was written; this PR just wires them in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): stub discord module in chunker test for CI PR sonichi#611 wired tests/*.test.py into npm test, which exposed that discord-chunker.test.py loads src/discord-bridge.py via importlib — and discord-bridge.py does `import discord` + `discord.Intents.default()` + `discord.Client(intents=...)` at module top level. CI runners don't have discord.py installed (it's not in the npm-managed deps; locally we have it because we run discord-bridge as a service). Result: ImportError mid-test, CI red on `discord-chunker.test.py`. Fix: try-import-fallback. If `import discord` succeeds (local dev with discord.py installed), no-op. If it raises ImportError (CI), inject a minimal stub module with just enough to satisfy the module-load-time references — the chunker itself is pure string manipulation and never calls into discord at runtime, so the stub never matters. Verified: - locally with real discord installed: 7+7 cases pass - simulated CI with discord masked via meta_path: 7+7 cases pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): also bypass DISCORD_BOT_TOKEN gate for chunker test Previous fix (b10d9e3) handled the `import discord` ImportError but exposed a second module-load gate: discord-bridge.py reads DISCORD_BOT_TOKEN from `~/.claude/channels/discord/.env` and `exit(1)`s if absent. CI runners don't have that file. Bypass by writing a fake .env at the expected path when it doesn't exist (locally the real one is preserved). Token is only read at module top — not used by `_chunk_for_discord` itself. Verified both paths: - local with real discord + real .env: 7+7 cases OK - simulated CI (discord masked via meta_path, HOME=tempdir): 7+7 OK 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi#622) Concrete bug: archiveFile() writes archived task files into tasks/archive/YYYY-MM/<taskId>.txt (PR sonichi#591 layout), but _isVoiceTask() only checked the legacy flat path tasks/archive/<taskId>.txt. After archive, voice classification silently fell back to false — the offline-forwarding gate at task-bridge.ts:535 then misclassified archived voice tasks as non-voice and suppressed DM-fallback delivery. Fix: glob the month subdirs under tasks/archive/ (regex-gated to YYYY-MM names so stray subdirs like `done/` are skipped). Legacy flat-archive path retained for any task archived pre-sonichi#591. Tests: new tests/task-bridge-isvoice.test.ts — 8 cases covering live / processed / legacy-flat / month-partitioned positive paths, non-voice negative path, stray-subdir negative control, missing-file return-false, and multi-month-subdir traversal. All pass. Out of scope (per VasiliyRad's review): the broader task-classification decision-boundary refactor stays in Path D Stage 2. This PR is just the path-drift fix. Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#614) Adds optional `auto_react: ["👀", ...]` field to per-channel access.json config. When set, the bridge calls `message.add_reaction()` at gateway-event speed (~hundreds of ms) BEFORE writing the task file. Task handler is expected to remove the reaction when posting the reply. Motivation: msze's AG2-mod test feedback. Visual "I saw your message" signal needs to land at gateway-event speed, not at agent-response speed (which can be 30-60s including bridge poll + watcher + Python startup). Implementation: - New `load_channel_auto_react(channel_id)` returns the configured list or empty list if not configured (zero-config-overhead default). - Hook in `_handle_discord_message` after access checks pass + before task file write. Best-effort try/except — react failure doesn't block task processing. Tested locally on #sutando-test in AG2 server with `auto_react: ["👀"]`. Bridge restarted (Mini uses nohup not launchd per `feedback_mini_bridges_no_launchd.md`). Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#613) Adds a 30min Timer in main.swift that runs `health-check.py --fix` and appends to logs/health-check.log — same pattern as the existing watcher-liveness (30s) and chips-refresh (120s) Timers. Replaces ~/Library/LaunchAgents/com.sutando.health-check.plist, which should be retired in the same cutover: launchctl bootout gui/$UID/com.sutando.health-check rm ~/Library/LaunchAgents/com.sutando.health-check.plist Behavior is intentionally identical to the retired launchd plist: - Same script (`src/health-check.py --fix`) - Same log file (`logs/health-check.log`, append mode) - Same interpreter path (`/opt/homebrew/opt/python@3.11/...` with `/usr/bin/env python3` fallback if homebrew python is absent) - Same 30min cadence (Timer interval = 1800s) Differences: - Fires once at startup so a fresh run is captured immediately rather than waiting up to 30min for the first Timer tick. - 60s throttle inside runHealthCheck() prevents double-fire if the Timer happens to align with the startup fire. - DispatchQueue.global(qos: .background) to avoid blocking the main run loop while waiting on the subprocess. Trade-off vs launchd: Sutando.app is currently NOT launchd-managed (starts via src/startup.sh at terminal-session launch), so the new Timer does NOT survive logout/reboot the way the launchd plist did. Followup task: make Sutando.app launchd-managed to close the gap. Per `notes/trio-design-current.md` Phase 2. Per Chi's call 2026-05-06: consolidate mechanical periodic things in the menu-bar app rather than scattering across launchd plists. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed) (sonichi#633) * feat(discord-bridge): AG2 auto-mod LLM-judge — full feature, dehardcoded Implements the AG2 auto-mod LLM-judge per `notes/ag2-moderator-policy.md` §6.1. Replaces and supersedes the 8-PR stack sonichi#625-sonichi#632 which was split for incremental review but had hardcoded AG2-specific values in the early PRs. This single PR ships the feature with NO hardcoding from the start. ## Components (all in `src/discord-bridge.py`) **Helpers + guardrails (G1+G2):** - `MOD_RULE_CONFIDENCE` per-rule thresholds (rule_5 = 0.90, rest = 0.85) - `_load_mod_config(guild_id)` — reads `mod_active` + `moderator_roles` - `_load_mod_server_config(guild_id)` — reads `escalation_channel`, `escalation_cc_user_ids`, `redirect_channel_jobs`. Defaults safely. - `_guild_id_of(message)` — extracts message.guild.id, None for DMs - `_is_moderator(member, role_ids)` — G1 mod-immunity gate - `_should_auto_action(verdict, threshold)` — G2 confidence gate - `_parse_judge_output(json_str)` — tolerant codex output parser **Codex judge wrapper:** - `MOD_JUDGE_SYSTEM_PROMPT` — generic phrasing, no server name baked in - `_format_judge_prompt` builds the per-batch prompt - `_codex_judge_batch` async wrapper with `-o <tmpfile>` for clean output **Action dispatchers (per-guild config-driven):** - `_post_mod_escalation(client, msg, ...)` — looks up channel + ccs from `_load_mod_server_config(_guild_id_of(msg))` - `_action_delete_and_escalate` (rules 1, 2, 6, 3-violates) - `_action_redirect_to_jobs` (rule 4) — uses `redirect_channel_jobs` - `_action_escalate_only` (rule 5) - `_action_polite_reminder` (rule 7) **Stateful detectors:** - `_DupeTracker` — Rule 3 cross-channel duplicate (5-min window, 3+ channels) - `_OffTopicStreakTracker` — Rule 7 off-topic streak (5+ msgs, cooldown) **Dispatcher + buffer:** - `RULE_TO_ACTION` map - `_dispatch_verdicts` routes verdicts to action functions - `_observe_for_mod` collects messages into `_mod_buffer` (5s/20msg flush) - `_flush_mod_buffer` runs codex judge + dispatches actions - `_mod_flush_timer_loop` background task for time-based flush ## All values per-guild via access.json ```json { "guilds": { "<guild_id>": { "mod_active": true, "moderator_roles": ["<role-id>", ...], "escalation_channel": <channel_id>, "escalation_cc_user_ids": ["<user-id>", ...], "redirect_channel_jobs": <channel_id> } } } ``` No bridge-side AG2 default. When config is absent → action logs + no-ops; mod_active stays false → fully observe-only. ## Tests 79 unit tests across 7 files: - `tests/discord-bridge-mod-judge.test.py` (18) — helpers + G1/G2 - `tests/discord-bridge-mod-judge-codex.test.py` (10) — codex wrapper - `tests/discord-bridge-mod-judge-actions.test.py` (9) — action dispatchers - `tests/discord-bridge-mod-judge-trackers.test.py` (14) — Rule 3 + 7 - `tests/discord-bridge-mod-judge-dispatcher.test.py` (11) — verdict routing - `tests/discord-bridge-mod-judge-buffer.test.py` (8) — buffer + flush - `tests/discord-bridge-mod-server-config.test.py` (9) — per-guild config All green locally. ## Activation See `notes/ag2-mod-merge-runbook.md` for the full activation path (pre-flight smoke test → access.json edit → bridge restart → live diagnostic). ## Default behavior unchanged Without `mod_active: true` in any guild, this is pure dead code at runtime. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): mod-judge bugs + integration tests (per MacBook review) Fixes 5 issues MacBook flagged on PR sonichi#633: HIGH severity: 1. Buffer drop on judge failure (`_flush_mod_buffer` line ~1158). Was: `_mod_buffer.clear()` BEFORE codex call → exception/empty-verdicts silently lost up to 20 messages. Now: snapshot without clearing, clear only after dispatch success. On failure batch stays in buffer for retry on next flush. 2. Rule 3 not wired (cross-channel duplicate). `_DupeTracker` existed but `.add()` was never called. Now `_flush_mod_buffer` feeds every batch entry into the tracker and collects trigger msg_ids; the prompt builder receives a `Rule 3 candidates: [...]` rules_context line so codex can mark them rule_3 and judge violates_server_rules. 3. Rule 7 dead in dispatcher. `_dispatch_verdicts` was skipping rule_7 verdicts without feeding the streak tracker. Now feeds off_topic=True into `_OffTopicStreakTracker.record()` and fires `_action_polite_reminder` when streak triggers. MEDIUM severity: 4. Rule 3 escape hatch unreachable. `_parse_judge_output` stripped `violates_server_rules` field that `_dispatch_verdicts` later branched on, so legit cross-posts always defaulted to delete. Now preserves the field at parse time. 5. Mention-injection in escalation post. Suspect message containing `@everyone` / `<@user>` would emit real pings when replayed in mod channel. Now: (a) `_sanitize_for_quote()` inserts ZWSP after each `@` to break parsing while preserving readability; (b) `_post_mod_escalation` passes `discord.AllowedMentions(everyone=False, roles=False, users=cc_ids_only)` to enforce at API level. NEW INTEGRATION TESTS (was the missing coverage MacBook flagged): `tests/discord-bridge-mod-judge-integration.test.py` — 8 cases covering the observe → flush → parse → dispatch path end-to-end: - a) batch retained on codex exception - b) batch retained on empty verdicts - c) batch cleared on dispatch success - d) Rule 3 dupe-tracker wired (3 ch's with same content → rule_3 context) - e) Rule 7 verdict feeds streak tracker - f) violates_server_rules preserved by parser - g) Rule 3 with violates_server_rules=False escalates-only (no delete) - h) @everyone in suspect content gets sanitized; allowed_mentions set Total tests: 79 → 87, all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): mod-judge v2 bugs (per MacBook v2 review) Three issues flagged in v2 cold review on top of commit a943d45: HIGH — concurrency race in _flush_mod_buffer Snapshot under buffer-lock then release for codex+dispatch; a parallel threshold-eager flush could re-snapshot the same buffer and double-judge. Fix: introduce _mod_flush_lock (asyncio.Lock) wrapping the entire flush body; serializes flushes so the second invocation only enters after the first has cleared its batch_ids on success (or left them in place on failure for retry). Body extracted to _flush_mod_buffer_inner so tests can drive concurrency directly. MEDIUM — _DupeTracker bucket not cleared after Rule 3 trigger Bucket retained the firing 3-channel evidence for the full 5-min window, so a single follow-up post in a 4th channel re-triggered Rule 3 with weak (mostly-stale) evidence. Fix: track triggered (user_id, text) keys during the add loop and call _DupeTracker.clear() on each after dispatch success. Failure path skips clear so retries can still re-trigger. MEDIUM — Rule 7 mod-reset was dead code Two breaks: (a) _observe_for_mod returned silently for mod authors so they never reached _OffTopicStreakTracker; (b) clean-verdict path in _dispatch_verdicts read getattr(v, "_is_mod", False) on a dict (always False). Fix: feed mod messages into the streak tracker directly with is_mod=True from _observe_for_mod (preserves G1 buffer immunity); drop the dead getattr in dispatcher (mods never reach that path so is_mod is unconditionally False there). Tests: 3 new integration cases added on top of the existing 8: i — concurrent _flush_mod_buffer calls invoke codex once, not twice j — Rule 3 trigger clears bucket; follow-up single post does not refire k — mod author posting in mod-active channel resets Rule 7 streak All 11 integration cases plus the rest of the bridge suite still pass. --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nichi#634) Removes the Mail.app-via-osascript alert that fired when health-check.py --fix left issues unresolved. Three reasons: 1. **Broken in practice.** NOTIFICATION_EMAIL env was never set on this fleet → AppleScript created a no-recipient outgoing message → silently failed inside `try ... except Exception: pass`. Plus the alert_body uses `\\n` which becomes literal backslash-n, not newlines, in the AppleScript string. Plus unescaped quotes in issue details could break the AppleScript string entirely. 2. **Redundant.** The trio's LEARN coverage check (`scan_health_anomalies` in `~/.sutando-memory-sync/skills/personal-learn-loop/scripts/coverage-scanners.py`) already produces a `surface_owner` proposal (high confidence, high urgency, auto_safe=true) on every health-check non-zero exit, which ACT routes to the owner's Discord DM. That's the canonical alert surface now. 3. **Single canonical surface > email + DM races.** Discord DM is the established notification path (per `feedback_dm_pending_questions.md` + `feedback_friction_dm_fallback_is_intentional.md`). One channel, one mental model, one place to grep. Pure deletion — no behavioral change beyond stopping a broken email path that was never reaching anyone. The 1-2 line "issue(s) found:" stdout banner from `health-check.py` (used by Sutando.app's 30min Timer + trio CURATE) is unchanged. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nichi#615) The Tasks panel grew to top-30 (was 8) so completed work was crowding out active items, making the "what's still running?" glance harder. Now done tasks are filtered out by default; the header shows a "show N done" chip when there are any, toggling to "hide N done" when revealed. Pending /working/error tasks always render. Toggle state persists in localStorage under sutando-show-done-v1. When all tasks are done and the toggle is off, the list renders empty but the header still shows the toggle so the user can reveal them. Co-authored-by: Vasiliy Radostev <vasiliyr@mac.mynetworksettings.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#602) * fix(voice-agent): close idle Gemini transport to stop phantom turns Voice-agent kept the Gemini Live session alive even with `clientConnected=false`. Every ~9-min Gemini reconnect ("GoAway") then produced a phantom assistant turn 2-3s later — sometimes a tool call (`save_meeting_note`, `open_url`, `work`) that polluted notes files, opened browser tabs, or wrote phantom task files. Reproduced 100% on Studio + verified on Maddy. Adds a 60s idle teardown: - on `handleClientDisconnected`, schedule a timer - timer fires `transport.disconnect()` if no client returned in time - on `handleClientConnected`, cancel any pending timer - also armed at boot so a freshly-started voice-agent with no client doesn't keep an idle session open CLOSED is a fixed point when `clientConnected=false` (existing health monitor only resumes CLOSED→CONNECTING when a client is present), so no phantoms can fire until a real client reconnects. Verification (post-patch): - 15:10:50 boot teardown fired, state CLOSED - 15:11:20–15:13:51 stayed CLOSED through 4 would-be reconnect windows, zero phantoms - 15:13:54 client connected → Gemini reopens automatically - 15:14:59 disconnect teardown fired exactly 60s after client gone, state CLOSED Pre-patch baseline: 90 min idle = 10 phantoms. Post-patch: 6 min idle = 0 phantoms + 2 confirmed teardown events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sonichi#602): expose IDLE_TEARDOWN_MS via SUTANDO_VOICE_IDLE_TEARDOWN_MS env Per Mini's sonichi#602 review non-blocker note: hardcoded 60s default is fine for current evidence, but tuning means a recompile. Read from `process.env.SUTANDO_VOICE_IDLE_TEARDOWN_MS` with the same 60s default — hosts with different connect/disconnect churn can adjust without code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-style posts (sonichi#620) * feat(discord-bridge): [reply: <message_id>] directive for context-aware Discord-reply posts Agents can now opt result/proactive messages into Discord's reply-style posting (the [Replying to ...] quote header) by embedding `[reply: <message_id>]` in result text. Bridge parses + strips the directive before sending, and POSTs the first text chunk with message_reference set. Why: msze (AG2 server) flagged 2026-05-06 that welcome posts and other context-following responses lose context when posted as fresh messages. Discord-reply attachment via message_reference keeps thread coherence. Behavior: - No directive → unchanged (fresh message, current default). - Directive present → first chunk uses MessageReference; subsequent chunks are fresh (Discord allows one reply-anchor per message). - fail_if_not_exists=False so a deleted referenced message doesn't block the post — falls back to fresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sonichi#620): use channel.id not int(channel_id) for MessageReference channel_id was undefined in poll_results() scope — the resolved channel is the Channel object `channel`, not a stringified id. Both MacBook and Mini bot cold-reviews flagged this independently. Without the fix, any [reply: <id>] directive would NameError at the bridge layer. Per cold reviews of PR sonichi#620 (cross-bot convergence). * test(discord-bridge): cover [reply: <id>] directive — 3 cases Tests catch the class of bug fixed in 546b6ec (NameError on undefined channel_id in the MessageReference construction). 3 cases: a) directive present → channel.send called with reference set, message_id matches the directive payload, channel_id matches channel.id (the resolved Channel object), fail_if_not_exists=False; directive stripped from sent content b) no directive → reference is None (backwards-compat preserved) c) multi-chunk text → only first chunk has reference (Discord allows one reply-anchor per send) Drives poll_results() with a mock channel + tight asyncio.wait_for timeout to capture send args from one iteration. Both MacBook + Mini cold-reviews flagged the bug independently; this is the test that would have caught it before the v1 push. Closes the unchecked PR sonichi#620 test-plan item: > [ ] Live test through the bridge: write a result file with [reply: <id>], > verify the bridge POSTs reply-style. --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#624) * feat(discord-bridge): welcome user on first post in welcome_channel When a new user posts their first message in a guild's configured welcome_channel, the bridge replies with the canonical welcome template (notes/ag2-welcome-template.md) and marks the user as welcomed. Subsequent posts from the same user in the same guild are skipped. Why - Replaces the closed PR sonichi#623 (on_member_join + raid guards). Per msze 2026-05-06: "not critical it happens the second they join — welcome should be for the user creating a Hi/Welcome message." That redirect drops the privileged Server Members Intent requirement entirely; this PR uses the existing on_message handler. Behavior - Per-guild config: access.json `guilds.<guild_id>.welcome_channel`. No config → bridge ignores welcome-channel posts (current behavior preserved for guilds that don't opt in). - Trigger: any non-bot message in the configured welcome_channel from a user not yet welcomed in this guild. - Fires welcome with @-mention of the new user, then marks the user in state/welcomed-users.json (atomic tmp+rename) so subsequent posts are skipped. - Bot accounts skipped. - Wrong channel (e.g. user posts in #general while #welcome-and-intro is configured) → skipped. - Short-circuits the rest of message processing (the "Hi" message itself is not treated as a task). Activation requirements (NOT shipped — manual) - Add `welcome_channel` to access.json under guilds.<guild_id>. AG2 destination per msze: 1307539994751139893 (#welcome-and-intro). - Restart the bridge. - No privileged-intent toggle required (drops the sonichi#623 cross-server scope concern). Tests tests/discord-bridge-welcome-on-first-post.test.py — 10 helper invariants: per-guild channel lookup, missing/malformed access.json, template read + missing-template empty signal, welcomed-users state round-trip across multiple users + multiple guilds, malformed state file → empty, _is_user_welcomed pure check, _should_welcome_first_post happy path + all 5 skip reasons, dedup across two messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(discord-bridge): per-guild welcome_template — drop AG2 hardcoding Per Chi's review on PR sonichi#624: remove AG2-specific defaults from the bridge code. The hardcoded WELCOME_TEMPLATE_PATH = REPO/notes/ ag2-welcome-template.md is gone. Operators set the template path per guild in access.json alongside welcome_channel: {"guilds": {"<guild_id>": { "welcome_channel": "<channel_id>", "welcome_template": "<path-relative-to-repo-or-absolute>" }}} If welcome_template is missing, welcome doesn't fire (logged with reason=no_welcome_template_configured). No bridge-side fallback. Schema details - Both fields are required for welcome to fire. Either alone is a no-op (with skip-reason logged). Operators that want to opt in for AG2 set both to the AG2 channel + AG2 template path; other guilds remain unchanged. - Path is expanded with ~user; resolved relative to REPO if not absolute (matches the existing `[file:]` directive's semantics). API changes - New `_load_welcome_config(guild_id) -> (channel_id, template_path)` is the canonical lookup. The pre-existing `_load_welcome_channel` is kept as a thin shim. - `_read_welcome_template(template_path)` now requires the path arg; returns "" on None, empty, or missing-file. - `_should_welcome_first_post(...)` gains a welcome_template_path parameter; skips with reason=no_welcome_template_configured if unset. Tests updated - `case_load_welcome_channel_per_guild` extended to verify per-guild template path resolves AND the channel-only-no-template case returns (id, None). - `_read_welcome_template` tests now exercise None/empty/missing path variants. - `_should_welcome_first_post` tests cover the new no_welcome_template_configured skip reason. All 10 helper invariants pass. Full npm test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(discord-bridge): rename welcomed-users state to discord-welcomed-users Per Chi's PR review: namespace the dedup state file as Discord-specific to future-proof against Telegram/voice welcome flows that might land under state/ later. Renames: - state/welcomed-users.json → state/discord-welcomed-users.json - Module docstring + tests updated to match. Module constant name (`WELCOMED_USERS_FILE`) unchanged — file path is the public surface; the internal Python identifier stays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): atomicize welcome + constrain mentions in welcome body Two related defenses on the welcome flow: 1. Race: pre-fix, `_should_welcome_first_post` read the welcomed-users dict, then `await channel.send(...)` for the welcome body, then `_mark_user_welcomed`. Two near-simultaneous first posts from the same user could both pass the unwelcomed check during the await, producing a double-welcome. Fix: call `_mark_user_welcomed` BEFORE the send, so the second attempt sees the user already-welcomed and skips. Tradeoff: if `channel.send` fails after the mark, the user is silently never welcomed — recoverable by editing the state file. Better than a double-welcome. 2. Mention-injection defense in depth: the welcome body is operator- supplied via the per-guild `welcome_template` config, but a typo'd or compromised template containing `@everyone` / `@here` / role mentions would ring the whole server. Adding `discord.AllowedMentions(everyone=False, roles=False, users=[message.author])` to the `channel.send` call constrains the actual pings to just the welcomed user themselves. No new tests — existing welcome test doesn't assert on call signature. The atomic-mark behavior is implicitly covered by `case_j_dedup` (post-mark, second post is skipped). Existing 90-test suite still green. --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…closes (sonichi#617) Issue When the Gemini Live transport closes for a non-retryable reason (1011 credits depleted, 1011 quota exceeded, 401/403 auth, model not found), the voice-agent's health monitor keeps triggering reconnect every 60s. Each reconnect attempt fails after a 30s timeout, so the cycle is ~6 log lines per minute indefinitely while the upstream issue persists. Reproduced live this session (Studio): 17 instances of `code=1011 reason="prepayment credits depleted"` in a 5-min window while clientConnected=true. The voice-failure classifier already fires the OS notification + writes the actionable proactive-result file once per category, so the user has been told. The retry-loop just spams logs after that. PR sonichi#602's idle teardown only handles the *no-client* case (60s no client → close). Here a client is connected throughout, so sonichi#602 doesn't apply. Fix New `voiceFatalBackoffUntil` deadline (single closure-scoped variable, same scope as `lastReconnectAt`): - Bumped 5min into the future on every non-retryable close, including repeats of an already-notified category. Lives next to the existing notification dedup so the bump fires even when the per-category notification is suppressed. - Read by the 30s health monitor's reconnect-trigger condition — the monitor skips its `handleClientConnected()` call when the deadline is in the future. Reconnect cycle drops from 60s → 5min during a persistent outage. - Reset to 0 when the monitor observes state=ACTIVE — recovery from a transient close after the user fixed the upstream issue is not suppressed by a stale fatal window. No new env vars, no new constants exposed; one local `let` + four arithmetic uses. Defaults conservative (5min ≈ user-visible recovery delay after top-up). Verification - `npx tsc --noEmit` — clean - `npm test` — 154/154 ts + all py green - Trace through the 1011-loop scenario: 1. transport closes with 1011 → classifier → handleClose fires 2. retryable=false → bumps voiceFatalBackoffUntil to now+5min, then notifies (first time only — dedup as before) 3. health monitor 30s tick: state=CLOSED, client=true, 60s throttle elapsed, BUT `Date.now() > voiceFatalBackoffUntil` is false → skip reconnect 4. log dedup at line 1209 keeps the [Health] line silent (status unchanged) — no spam 5. ~5min later, voiceFatalBackoffUntil expires → reconnect fires 6. on success: state=ACTIVE → reset voiceFatalBackoffUntil=0, normal 60s throttle restored 7. on continued failure: classifier re-bumps voiceFatalBackoffUntil Why not unconditional 5min throttle Today's 60s throttle handles transient network blips well. Slowing every reconnect to 5min would degrade UX during normal flaps. Better to slow only the cases the classifier already knows are fatal. Why not stop reconnecting entirely on fatal The upstream issue (top-up, key rotation) often resolves without restarting voice-agent. A 5min auto-recovery window keeps the agent self-healing once credits/auth are back. Stopping permanently would require a manual restart. Cc: stack interaction with PR sonichi#602 (idle teardown) — both touch voice-agent.ts but in disjoint regions; merge order doesn't matter. Co-authored-by: Vasiliy Radostev <vasiliyr@mac.mynetworksettings.com>
…pervised invocation (sonichi#616) * feat(health-check): detect stuck proactive loop + queue pileup; OS-supervised invocation Issue On 2026-05-06 a user voice-queued 4 tasks. None were processed. tasks/ contained the 4 files for 20+ minutes; no result was delivered. The voice agent kept conversing fluently — confabulating plausible status ("the task timed out") because nothing in the pipeline could tell it the work hadn't run. Root cause Two failures stacked: 1. watch-tasks.sh exits on first event by design; its caller (the proactive loop) is supposed to restart it. The caller was stuck. 2. core-status.json showed status="running" with a ts 5 days old — a /proactive-loop pass crashed mid-execution and the loop never re-armed. Neither layer has a supervisor. health-check.py has emit_task_for_failures (PR sonichi#607) which would surface failures, but health-check is itself invoked from the proactive loop — when the loop is stuck, health-check doesn't run either. Circular dependency. Fix One mechanism, two parts: 1. Detection — two new check functions in health-check.py: - check_core_proactive_loop(): warns when core-status.json shows status="running" with ts older than threshold (default 600s). Defensive on missing/malformed file. - check_task_queue(): warns when tasks/*.txt count > 3 AND oldest file > 300s. Both signals together filter transient spikes from legitimate heavy use. Excludes tasks/archive/ subdirs. Thresholds configurable via SUTANDO_HEALTH_LOOP_STALE_SEC, SUTANDO_HEALTH_QUEUE_AGE_SEC, SUTANDO_HEALTH_QUEUE_COUNT. 2. Independent invocation — launchd LaunchAgent: - src/launchd/com.sutando.health-check.plist (template). - src/install-health-check-launchd.sh installs it (renders paths, launchctl bootstrap gui/$UID; idempotent; --uninstall mode). - Runs `python3 src/health-check.py --emit-task --notify-on-fail --quiet` every 60s, supervised by launchd. Independent of any Sutando process — fixes the circular dependency. 3. New surface — `--notify-on-fail`: - Companion to --emit-task. Same dedup contract (per-failure-set hash, 1h cooldown), separate state file (state/health-last- notified.json) so the surfaces can't suppress each other. - Why two: emit-task only delivers if the agent is alive to read tasks/. osascript runs at OS level — surfaces even when every Sutando service is dead. Robustness comes from disjoint failure modes. Strictly opt-in. startup.sh unchanged. Default onboarding path unchanged. No new TCC permissions. User who installs sees one macOS "Background Item Added" banner and a Login Items entry titled "Sutando — Health Check" they can disable any time. Test - tests/health-check-stuck-loop.test.py: 17 cases covering missing/ malformed/idle/fresh/stale core-status, empty/small/large/old/ archive-excluded queue states, and notify dedup (empty-no-call, within-cooldown, different-sets, separate-state-from-emit). - npm test: passes (existing emit-task tests + new stuck-loop tests). - tsc --noEmit: clean. - Plist template: plutil -lint OK after sed substitution. - Live: ran health-check on the actual stuck system; new checks report ok now (core recovered mid-investigation), confirms they don't false-alarm on healthy state. * fix(health-check): position launchd job as fallback (rename + 5min cadence) Cold-review feedback from Mini on PR sonichi#616 surfaced a direct architectural collision with PR sonichi#613: - sonichi#613 retires `~/Library/LaunchAgents/com.sutando.health-check.plist` in favor of an in-process Timer in Sutando.app (30min, primary). - sonichi#616 (this branch) was installing a NEW plist with the *same* label and 60s cadence, fighting sonichi#613 for ownership. Resolution per Option 2 in Mini's review (preserves the "Sutando.app dead" guarantee that motivated sonichi#616, eliminates the label collision): 1. Rename label: `com.sutando.health-check` → `com.sutando.health-check-fallback` - File renamed: `src/launchd/com.sutando.health-check.plist` → `src/launchd/com.sutando.health-check-fallback.plist` - Plist `<Label>` updated to match. - Install script `LABEL` updated. - health-check.py docstring + inline comments updated to point at the new label. 2. Cadence: 60s → 5min (StartInterval 300, ThrottleInterval 60). - 60s was over-engineered relative to the underlying check thresholds: `check_core_proactive_loop` warns when status=running ts is older than `SUTANDO_HEALTH_LOOP_STALE_SEC` (default 600s); `check_task_queue` requires oldest file age > 300s. Polling faster than 5min adds no detection signal — just CPU + log churn. - 5min is fast enough to catch the 2026-05-06 stuck-loop scenario promptly (queue had 4 files unprocessed for 20min — would have fired on the 5min check at T+5). - Slower than sonichi#613's 30min Timer (now primary), so the fallback still accelerates detection when Sutando.app is alive. 3. Reposition in docs/comments as the OS-level "all of Sutando dead" safety net. Layering: - Sutando.app Timer (sonichi#613) = primary, 30min, in-process while menu- bar app is running. - launchd job (this PR) = fallback, 5min, runs even when Sutando.app is signed out / crashed / not yet launchd-managed. - Once sonichi#613's Phase-2 makes Sutando.app launchd-managed, this job becomes belt-and-suspenders — keep it for stuck-loop scenarios where Sutando.app's Timer might be deadlocked alongside the proactive loop. No behavioral change to the detection logic (`check_core_proactive_loop`, `check_task_queue`, `notify_for_failures`) or tests. All 17 stuck-loop / queue-pileup invariants still pass. Now disjoint with sonichi#613 — both can land in any order. --------- Co-authored-by: Vasiliy Radostev <vasiliyr@mac.mynetworksettings.com>
…TS-1-HD (sonichi#638) * feat(openai-tts): add skill for offline speech synthesis via OpenAI TTS-1-HD Adds a self-contained skill that renders text to mp3 via OpenAI's tts-1-hd endpoint. Reads OPENAI_API_KEY from .env. Defaults: voice=coral, model=tts-1-hd, output path=results/openai-tts-{epoch}.mp3. Use for: video narration, demo voiceovers, audio notes, accessibility renderings. Distinct from voice-agent's bidirectional Gemini Live audio — synthesis only, not real-time conversation. Tests cover argument plumbing + payload shape via a mocked curl in PATH (no live API calls). All 5 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(openai-tts): slim synthesize.sh + drop test commit Slimmed synthesize.sh from 92 → 33 LOC (removed --file, --model, verbose error wrapping). Slimmed SKILL.md from 52 → 36 LOC. Removed the test file per owner request — verified manually with live API calls. Net branch state: 69 LOC (skill files only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(openai-tts): add mocked-curl smoke test (4 cases) Restores the test file dropped in f760323. Per Chi's PR sonichi#638 review note that "a future iteration might add a mock-curl test if you start using this in CI'd pipelines" + Susan's request to ship the test. Mocks `curl` in PATH so we can verify argument plumbing + payload shape without hitting the OpenAI API. 4 cases: defaults, --voice override, --out path honored, missing text errors out. All green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#637) * feat(claude-codex): add --goal mode for spec-driven one-shot builds Wraps Codex's interactive `/goal` slash command for non-interactive use. The flag prepends `/goal ` to the prompt and forces `--full-auto` so Codex can write files unattended. Use for self-contained HTML/CSS/JS prototypes built from a tight spec — where one-shot generation with a known cost ceiling beats iterative collaboration. Default skill behavior (`codex exec`) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(claude-codex): guard --goal against --review combo + double-prefix Addresses Chi's two non-blocking observations on PR sonichi#637: 1. --goal --review now fails fast with a clear message (codex review's diff+prompt input model has no semantic for /goal-prefixed prompts). 2. --goal -- "/goal X" no longer double-prefixes; existing /goal prefix is left intact. Smoke-verified all 3 paths against a mock codex: - --goal --review → exits with "cannot be combined with --review" - --goal -- "/goal X" → prompt stays "/goal X" (no doubling) - --goal -- "X" → prompt becomes "/goal X" (single prefix) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(claude-codex): cover --goal flag and 70626d8 guards Mocks the codex binary in PATH to verify: 1. --help documents --goal 2. --goal prepends "/goal " + enables --full-auto 3. Default mode untouched by --goal logic 4. --goal --review fails fast with "cannot be combined" message 5. --goal -- "/goal X" does not double-prefix 5 tests, all green. No live codex calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(claude-codex): always pass --skip-git-repo-check to codex exec Without this, codex bails immediately on any non-git workdir ("Not inside a trusted directory and --skip-git-repo-check was not specified"). This bit me when running --goal in /tmp/goal-tiny — both parallel runs hung silently for 13 minutes before I caught the diagnostic in stderr. The wrapper is meant to be invoked with explicit `--cd` against arbitrary workdirs, so always passing the flag is the right default. `codex review` mode unchanged — review needs to be inside a repo, so the check is appropriate there. Test added: regression check that --skip-git-repo-check is always in the codex argv. 6 tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(claude-codex): parse `--goal` flag in slash-command arguments Previously /claude-codex slash command always called codex-run.sh without --goal. This patch documents the parser path: if ARGUMENTS starts with "--goal ", strip the prefix and call codex-run.sh --goal -- "$PROMPT". User-facing benefit: `/claude-codex --goal Build trivia.html...` now routes through Ralph-loop mode without the user having to know the underlying bash invocation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#639) * feat(discord-bridge): silently escalate Discord-state task references Per msze_'s 2026-05-07 directive: when a non-owner task body references Discord-side state (channel mention `<sonichi#1234>` etc.) that codex sandbox cannot resolve, the bridge now intercepts BEFORE writing the task and: 1. Resolves the target guild (origin guild, else fallback to first referenced channel's guild) 2. Posts a silent escalation note to that guild's `escalation_channel` (already used by sonichi#633's mod-judge for Rules 1/2/3-violates/5/6), with sender + origin + referenced channels + truncated task body 3. Writes a new "already_escalated" tier instruction telling the agent to NO-REPLY archive — no public "Sandbox unavailable" string Closes the structural-failure mode where codex sandbox would hang for 6+ minutes trying to resolve `<#...>` references it has no Discord access for, then leak the cold "Sandbox unavailable; refusing non-owner task." fallback into public channels. This is "fix 1" of Chi's 2-step plan. Fix 2 (Discord-aware tool that resolves `<#...>`/`<@...>` in the agent's context BEFORE delegating to codex, so the sandbox receives resolved text instead of opaque refs) ships separately. Tests: tests/discord-bridge-discord-state-detection.test.py — 10 cases covering pure detection (a-e) + the silent-escalate flow's branches (f-j: no-refs / guild-origin-with-config / DM-origin-via-referenced / no-escalation-channel / unresolvable-target-guild). All green; full bridge suite still green. * fix(sonichi#639): address MacBook review — pending_replies leak + DM-fallback misroute Two real bugs caught by MacBook's cold review of sonichi#639's first pass: 1. pending_replies LEAK (review #2). The original silent-escalate path used an `already_escalated` tier instruction that told the agent to NO-REPLY archive — which meant no `results/task-*.txt` ever appeared, so the task entry sat in `pending_replies` (in-memory + persisted via save_pending_replies) until the 7-day age-out at startup. State leakage at low volume; recovery noise at higher volume. Fix: when `already_escalated=True`, the bridge now `return`s BEFORE the task-file-write + pending_replies insertion. No agent invocation, no leak. The bridge's silent-escalate post is the only side-effect. The unused `already_escalated` tier instruction removed (was dead code under the new flow). 2. DM-fallback can MISROUTE (review #3). The original DM-origin fallback accepted any `<#...>` ID whose `fetch_channel` returned a guild — meaning an arbitrary DM containing a `<#...>` for any guild the bot is in could route an escalation post to that guild's `escalation_channel`. Information leak / spam vector. Fix: DM-origin path now requires (a) the resolved channel to be a guild text/thread channel (not voice/category/etc.), AND (b) the target guild to have `mod_active=True` (explicit opt-in for moderation flow, the same gate sonichi#633's mod-judge uses). Origin-guild path unaffected — when the message itself was posted in a guild channel, that guild's escalation_channel is used directly without the extra DM-only gates. Tests updated: - case_h now mocks `_load_mod_config` returning (True, []) for the target guild (DM-origin gate must pass) - 3 new cases: - case_k: DM origin + mod_active=False → silent NO-REPLY, no post - case_l: DM origin + wrong channel type (category) → silent, no post - case_m: guild origin + mod_active=False → posts anyway (DM gates don't apply to origin-guild path) 13 cases all green; full bridge suite still green. * fix(sonichi#639): add sender-auth gate to DM-origin path (per MacBook v3 review) MacBook's follow-up review on v2: `mod_active=True` is the right opt-in gate but NOT a sender-auth gate. A team-tier-trusted DM sender is "trusted by Sutando" (per access.json's allowFrom/allowTeam) but that trust doesn't extend to driving escalations into a guild they're not a member of. Adds DM-origin gate 2: sender must be a member of the target guild. Implementation uses `ch_guild.get_member(sender_id)` (fast cache path) with `fetch_member` fallback. If sender is not a member of the resolved target guild, the helper logs + continues to next referenced channel (or falls through to silent NO-REPLY if no candidates pass). MacBook also pointed out (correctly) that we should NOT overload `escalation_cc_user_ids` as the auth predicate — that field is "who gets pinged on escalations," and using it for "who can drive DM-origin routing" couples two unrelated abstractions. Sender-membership is the cleaner gate (and could be tightened to "sender has a moderator role in the target guild" later if `escalation_cc_user_ids`-style authorization isn't sufficient). Tests updated: - case_h now mocks `_MockGuild` with member dict including the sender - 1 new case_n: DM origin where sender is NOT a member of target guild → returns True silently, no post 14 cases all green; full bridge suite green. * fix(sonichi#639): explicit fetch_member exception handling + tests (v3 review) MacBook's v3 follow-up review caught two real issues with the sender-auth gate added in v3: 1. **`fetch_member` raises NotFound, doesn't return None.** discord.py's `Guild.fetch_member()` raises `discord.NotFound` (404) when the user is not in the guild. v3 had `try/except Exception` which DID catch NotFound, but the test suite didn't exercise the raises-path, so reviewers couldn't verify the behavior. The catch is now explicit with separate branches for NotFound (silent, expected), Forbidden (logged, fail-closed), HTTPException (logged, fail-closed), and unexpected (logged, fail-closed). 2. **HTTP cost on hot path.** v3's `fetch_member` fallback incurs one REST call per DM-origin task that lands on this code path (members intent not enabled). MacBook flagged this as a perf concern. In practice the path is gated by `<#...>` ref present + `mod_active=True` for resolved guild + valid channel type — narrow enough that REST volume is small. Filing as a follow-up perf-watch (memoization could land if traffic ever justifies it), not blocking the fix. Tests grew 14 → 16 cases: - case_n now uses `_MockGuild.fetch_member` that raises `_NotFound` for non-member (matches discord.py's actual contract) - case_o: fetch_member raises Forbidden → silent NO-REPLY, no post - case_p: fetch_member raises HTTPException → silent NO-REPLY, no post Mock now supports `fetch_raises` parameter on `_MockGuild` to inject specific exception classes; mirrors discord.py's `discord.NotFound / Forbidden / HTTPException` hierarchy via stub classes. All 16 cases green; full bridge suite green. * fix(sonichi#639): role-mention ValueError in cc_ids extraction (v4 review) MacBook's v4 line-level review caught a real bug shared with the old _post_mod_escalation path: cc_ids extraction used `s.startswith("<@") and s.endswith(">")` which matches both user mentions `<@123>` AND role mentions `<@&123>`. Role-mention path: `"<@&123>".strip("<@>")` → `"&123"` → `int("&123")` raises ValueError, killing the entire escalation post. Same pattern in two places — copy-pasted from `_post_mod_escalation` (line ~947) into v1's `_silent_escalate_for_discord_state` (line ~1009). Per `feedback_unit_test_copied_helpers.md` exactly. Fix: new module-level helper `_extract_user_id_mentions(mention_strs)` backed by anchored regex `^<@(\d+)>$` (rejects role-mention `&` digit prefix structurally). Both call sites now use the helper. Empty/None input returns [] without raising. Also: outer guard in task-write block now sets `already_escalated=True` on unknown exception (was False = fall-through to public codex run). Per MacBook's "fail-silent matches msze_'s 'don't surface internal errors publicly' intent" — the previous fail-open let a broken escalate path leak the cold "Sandbox unavailable" string into Discord, which is exactly what msze_ asked us to avoid. Tests: 16 → 18 cases. - case_q: helper-level — mixed user/role/garbage list returns user IDs only; empty/None safe; role-only → [] (no crash) - case_r: end-to-end — escalation_ccs with `<@&999>` between user mentions still posts (pre-fix would ValueError out of the path) All 18 cases green; full bridge suite green. --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net>
…ctive (sonichi#636) Issue: every cron-fired `/proactive-loop` re-runs the activation skill, which unconditionally calls `/loop` with the per-pass body as its prompt. `/loop` always creates a new recurring CronCreate entry — no dedup. With `crons.json` setting `main-loop` (`*/5 * * * *` → `/proactive-loop`) as the canonical driver, every */5 fire spawns a fresh */10 cron with the body, all firing forever. Cron list grows unboundedly; per-pass cost compounds. Fix: 1-line guard at the top of `## Start the loop`. If the CronList already drives the loop (main-loop or a prior /loop arming), skip the section and execute the per-pass body directly. Otherwise behavior is unchanged — `/loop <interval>` arms the loop as before. Why a guard rather than dropping the section: users without `main-loop` in `crons.json` (or running `/proactive-loop` ad-hoc in a session without `/schedule-crons`) still need the self-arming path. Doesn't compete with sonichi#534 / sonichi#565 (both restructure the steps); this is the first place either restructure would also need to gain. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…is voice (sonichi#618) Issue Voice synthesis voice was hardcoded to 'Puck' at src/voice-agent.ts:821. Changing it required a code edit + restart; users who wanted to try a different prebuilt Gemini Live voice (Aoede, Kore, Charon, etc.) had to modify source. Fix Read VOICE_NAME from process.env, fall back to 'Puck' on unset. Matches the existing VOICE_* prefix used by VOICE_MODEL and VOICE_NATIVE_AUDIO_MODEL. Document in .env.example under MODEL CONFIGURATION with the list of prebuilt voice names and the close-code (1007) that fires on an unsupported name. Test - tsc --noEmit: clean. - npm test: 154/154 pass (no test changes — single-line env-var read). - Live verification: VOICE_NAME=Aoede resolves to "Aoede"; unset resolves to default "Puck". Co-authored-by: Vasiliy Radostev <vasiliyr@mac.mynetworksettings.com>
…i#640) * feat(sutando-app): add --emit-task flag to runHealthCheck Sutando.app's menu-bar Timer was firing health-check.py with --fix only, so failures it can't auto-fix went silent. The --emit-task flag (already implemented in health-check.py with 1h cooldown per failure-set hash) writes tasks/task-health-{ts}.txt for the bridge to pick up as owner-tier tasks. Gives the trio's `scan_health_anomalies` surface_owner path a redundant peer in the file-bridge layer, so failures the trio suppresses by cooldown (or ACT's LLM step archives by judgment) still reach the agent. Per Chi 2026-05-07 PT after observing 3 stale services (voice-agent, web-client, sutando-app) silently suppressed in trio cooldown all day. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): --emit-task surfaces residual failures, not pre-fix set Per Mini's PR sonichi#640 v1 review (verified): the original code emitted the task BEFORE the --fix loop, so `--fix --emit-task` together meant "emit pre-fix failures, then fix some". Created owner tasks for failures resolved in the same invocation. This commit moves emit_task_for_failures to AFTER the fix loop, and when --fix ran, re-runs checks to capture the residual state. The 2s delay between fix loop and re-check matches the existing per-service sleep(1) restart budget. Behavior matrix: --emit-task only → emit on initial checks (= residual, no fix) --emit-task + --fix → re-run checks after fix loop, emit on residual --notify-on-fail → unchanged: notifies on INITIAL set (launchd fallback wants the alert immediately) Achieves PR sonichi#640's stated contract ("redundant peer for failures the trio suppresses") rather than the noisy pre-fix variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): split emit-task logic by --fix state to restore --quiet coverage PR sonichi#640 v2 (commit f9a88e4) moved the emit-task block to end-of-main so it'd surface the post-fix residual set. Mini caught a regression: the launchd fallback's `--quiet --emit-task --notify-on-fail` invocation bypassed the moved emit via the quiet-path `sys.exit(1)` before reaching end-of-main. So no-fix callers stopped emitting tasks at all. Fix: split by --fix state. - `--emit-task` without `--fix` → emit immediately (initial check IS the residual; placed BEFORE --json / --quiet early exits) - `--emit-task` with `--fix` → emit at end-of-main on residual after fix loop (re-runs checks) Both paths reach emit. No-fix path no longer bypassed by --quiet exit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ew (sonichi#641) Two follow-ups noted (non-blocking) during sonichi#618 cold-review: 1. Module-level constant matching VOICE_MODEL / VOICE_NATIVE_AUDIO_MODEL pattern at src/voice-agent.ts:103-104. Inline `process.env.VOICE_NAME` was the only inline env access in the speechConfig section. 2. Startup banner: VOICE_MODEL + VOICE_NATIVE_AUDIO_MODEL log at startup (src/voice-agent.ts:1304-1305). VOICE_NAME wasn't logged, so users couldn't see which voice they'd get before first synthesis — relevant because invalid names fail at Gemini's WebSocket layer (close 1007), not at config parse. Tiny / no behavior change for default ('Puck' fallback preserved). Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings 36 commits from sonichi/sutando upstream — voice-agent idle teardown + reconnect backoff (sonichi#602/sonichi#617), VOICE_NAME env var (sonichi#618), discord-bridge mod-judge / welcome / reply-directive expansion, health-check stuck-loop detection + --emit-task (sonichi#616), claude-codex --goal mode (sonichi#637), openai-tts skill (sonichi#638), sutando-app Pause/Resume + Restart-CLI menus, 30min health-check Timer (sonichi#613), plus a stack of new tests. Conflicts (4) resolved: - package-lock.json: regenerated via npm install against merged package.json (upstream + origin's distribution deps). - src/health-check.py: kept origin's state_path() (SUTANDO_HOME-aware) and layered in upstream's legacy src/<name>.log fallback. - src/startup.sh: kept both — upstream's SUTANDO_ROOT export for child scripts AND origin's SUTANDO_HOME state-root + layered .env. - src/voice-agent.ts: kept origin's cloud-session telemetry helpers (closeCloudSession, isClientConnected) AND upstream's startup writeVoiceState(false) init that fixes the 2026-05-05 9h friction delivery delay.
rickchen007
pushed a commit
that referenced
this pull request
May 14, 2026
* Phone usage_events now emit from cleanupCall — phone.twilio.outbound and .inbound with duration seconds. Inbound calls open a cloud session too; both directions close on /twilio/status statusCallback, not just on hang_up. Fixes billing/cap blind spots for BYOK phone. * Funnel activation events wired: first_voice in writeVoiceMetrics, first_phone in cleanupCall, first_image in image-generation, and first_task in workTool. Closes the activation half of /admin/funnel. * Vision (browser-tools.ts) no longer silently falls back to BYOK on 402/429 — surfaces a clear "cap hit, top up" message. BYOK direct path now emits its own vision.gemini usage_event for free-tier visibility. * video.gen units are now real video seconds (ffprobe with 8s fallback) so per-second wallet debits match what was actually generated. * Session crash-safe handlers in voice-agent + conversation-server: SIGTERM / SIGINT / SIGHUP / uncaughtException / unhandledRejection close active sessions, flush the cloud-client queue, and record fatal errors before exit. * Auto-installed skills via cloud-skill-sync now emit a skill.install usage_event so dashboard-pushed installs are visible to admin. * PRODUCT.md Open Decision #1 pricing table reconciled to lib/billing/policies.ts (voice 200 cr/hr, phone 2.5 cr/min both lanes, video 6 cr/sec, plus the three cloud tools). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rickchen007
pushed a commit
that referenced
this pull request
May 16, 2026
…nalytics Captures the next-chapter plan. Approved beta users get 2 months of free Max via plan_comps; managed Gemini lands via ephemeral tokens (supersedes Wave 2.3 / 2.4); desktop gets its first in-app Skills surface; provider_cost_cents middleware closes the margin-analytics gap. New schema (0016-0019), new routes (voice-key, /admin/comps, /admin/rates), new desktop UI surfaces. Architectural principle #1 updated to reflect ephemeral tokens; open decisions 6-11 resolved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merged
3 of 13 tasks
qingyun-wu
pushed a commit
that referenced
this pull request
May 23, 2026
…art (sonichi#1056) * feat(catchup-after-startup): rebuild last-session context on fresh start New user-invocable skill `skills/catchup-after-startup/` — `/catchup-after-startup [hours]`, default 3h window. Reads everything Sutando persists across restarts and emits a single briefing into the new session's conversation buffer before the user types: session-state.md, open PRs by liususan091219, in-flight workspace/tasks, recent results, pending-questions filtered to un-resolved, last-N-hours voice/phone/ discord activity from data/conversation.sqlite (per-surface tables from sonichi#1051), recent chat from logs/conversation.log, recent git commits, build_log tail with staleness warning, health-check one-liner. Designed to be the FIRST action of any fresh proactive-loop session so the bot doesn't restart cold and forget what it was working on, what PRs are open, what channel a thread lives in. Recall half of sonichi#1032 (episodic event memory). The capture half (wiring event_log.py into every lifecycle point) remains a separate followup tracked under the same issue. scripts/install-hook.sh is an idempotent installer for a paired SessionStop hook in ~/.claude/settings.json. Without the hook, session-state.md is only written on PreCompact; a clean ⌘Q between compactions leaves the most-recent N-min window invisible to the next catchup. The skill still works without the hook (the other 9 sections are real-time persisted regardless) — installing closes the last gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address sonichi#1056 review: REPO default, pipefail, arg parsing, filter, auto-fire framing Combined fixes for @qingyun-wu's + Sutando-Mini's review comments on sonichi#1056. Both reviewers flagged the hardcoded personal REPO path as a blocker; the rest is polish + correctness. Script (skills/catchup-after-startup/scripts/catchup-after-startup.sh): - REPO default: env wins, else probe $HOME/Desktop/sutando, $HOME/Documents/sutando/sutando, $HOME/Documents/sutando, $HOME/sutando, $(pwd) for a real checkout (CLAUDE.md + skills/ + .git signature, same heuristic session-handoff.sh uses), else fall back to the conventional $HOME/Desktop/sutando. Auto-finds the repo without per-user env on any reasonable layout. (qingyun #1 + Mini #3) - Loud one-liner warns when REPO doesn't resolve to a real dir, so a misconfigured operator sees "REPO not found" instead of silently empty briefing sections. (qingyun #2) - $1 honored for the documented [hours] arg. Pre-fix, only CATCHUP_HOURS env was read; positional arg was silently ignored. (Mini #1) - set -o pipefail + restructure all piped commands to capture output into a variable + sentinel-on-failure, so a sqlite/git/python failure inside a pipe surfaces as "(failed: ...)" instead of silently empty output. The "(none)" vs "(failed)" vs "(missing)" distinction is the whole point of the empty-vs-failed contract. (Mini #2) - pending-questions resolution filter no longer over-matches bare 'DONE' / 'RESOLVED' substrings in body prose. Now requires an anchored marker — header containing ✅/Resolved/Dismissed/DONE, or a body line literally starting with ✅. (qingyun #4) SKILL.md: - Reframe "auto-fire" section. Pre-fix it implied auto-firing was built-in; in reality the slash command alone gives no auto-fire. Renamed to "Wiring for auto-invocation (operator-side, NOT in this PR)" with a sample markdown snippet operators can drop into their own proactive-loop SKILL.md. (Mini #4) - Explicit note that the sqlite section depends on sonichi#1051's per-surface schema; on a pre-sonichi#1051 db that section degrades to "(sqlite query failed)" while the other 9 sections still work. (qingyun #3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(catchup): jsonl-source recovery + install-hook probe + proactive-loop wiring (sonichi#1056) Three follow-up fixes on top of the address-review commit, addressing the last open blocker plus a real-incident fix plus the recovery mechanism that makes catchup work even when hooks fail. **1. Wire catchup into proactive-loop "On activation" step 1.** Addresses sonichi #4 — the SKILL.md claim "auto-fire as first action of fresh proactive-loop session" was not wired in code. Now: sentinel-guarded fire-once-per-fresh-session, skipped on cron-driven passes. **2. install-hook.sh resolves REPO at install time.** Real incident 2026-05-23 on Mac Studio: the old hook embedded \`\${SUTANDO_REPO_DIR:-\$HOME/Desktop/sutando}\` verbatim, so machines without the env set AND without a Desktop/sutando checkout silently no-op'd every SessionStop — session-state.md never got written. Catchup's primary source was therefore empty for weeks. Now the installer uses the same probe as catchup-after-startup.sh and bakes the literal path into the hook; misconfig fails loudly at install instead of silently at fire-time. **3. New section: "Previous-session transcript (last N h)".** session-state.md is a 50-line summary written by session-handoff.sh, which only fires when the hooks succeed. Both have failed in practice. The Claude Code project transcript .jsonl is always there (Claude writes it natively, no hooks) and is richer (full user/assistant turns vs a summary). When session-state.md is missing or stale, this is the source of truth for "what was happening last session". Reads the most-recent .jsonl in \$HOME/.claude/projects/<repo-slug>/ that's been untouched for >1min (skips the live session's file). Filters out tool-result envelopes, system reminders, interrupt markers. Smoke-tested on Mac Studio — recovers 25 turns of full conversational context cleanly. * fix(session-handoff): clear proactive-loop sentinel so next session auto-fires catchup (sonichi#1056) Paired with `skills/proactive-loop/SKILL.md` step 1's sentinel guard. Without this, the `proactive-loop-started.sentinel` from the just-ended session persists across restart — next session's first `/proactive-loop` sees the sentinel, skips `/catchup-after-startup`, and the auto-fire wiring is silently defeated for every restart after the first. One-line fix: SessionStop hook → session-handoff.sh now `rm -f`s the sentinel after writing session-state.md. Idempotent (no-op if sentinel absent). * feat(schedule-crons): fire /catchup-after-startup on fresh session (sonichi#1056) Wire catchup at the schedule-crons bootstrap entry so it runs synchronously at session start, not 5 min later when the first main-loop cron fires /proactive-loop. Post-sonichi#954 (Chi), start-cli.sh boots with `-- "/schedule-crons"`, making this skill the actual startup entry. With `822e630` of this PR catchup is wired into /proactive-loop step 1 — but that path only fires after the main-loop cron first triggers (~5 min lag, longer if the cron list grew the loop entry above */5). Wiring catchup here as well lets the same sentinel-guarded fresh-session check trigger immediately on bootstrap. Whichever path runs first touches the sentinel; the other skips. Idempotent. Defensive guard: if the `catchup-after-startup` skill dir isn't installed yet (pre-merge of this PR on a fresh checkout), step 0 silently skips. No hard dependency on this PR landing first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rickchen007
pushed a commit
that referenced
this pull request
Jun 17, 2026
conversation-server now points the configured number's VoiceUrl + StatusCallback at the live tunnel via the IncomingPhoneNumbers REST API on every start. Removes the manual Twilio-console paste AND self-heals ngrok's per-reboot URL change -- the #1 phone-setup friction. Best-effort: a failure logs the manual fallback and never blocks startup. Opt out with TWILIO_AUTO_WEBHOOK=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.