Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

Merge/upstream main - #1

Merged
rickchen007 merged 37 commits into
mainfrom
merge/upstream-main
May 8, 2026
Merged

rickchen007 merged 37 commits into
mainfrom
merge/upstream-main

Conversation

@rickchen007

Copy link
Copy Markdown

No description provided.

sonichi and others added 30 commits May 5, 2026 09:39
…#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>
liususan091219 and others added 7 commits May 7, 2026 21:23
…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
rickchen007 merged commit c98c0ef into main May 8, 2026
1 check passed
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>
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>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants