Repository navigation
fix(telegram-bridge): proactive dispatch must filter to numeric chat_ids - #6
Merged
Merged
Conversation
PR #4 added @username support to load_allowed() for incoming-auth. The proactive dispatch path still does `next(iter(owner_ids))` and then `int(owner_id)` — when allowFrom contains @username strings, int() raises ValueError and the message is silently dropped along with the file (it gets unlinked in the finally-style cleanup at the end of the block). Observed live 2026-05-13: an amazon-orders proactive notification was written to results/proactive-amazon-orders-{ts}.txt, the bridge claimed it via .sending rename, attempted dispatch, hit ValueError on int('abhikunte'), caught in the except, and unlinked the file without sending. Owner only got the update via direct-API fallback. Fix: filter owner_ids to numeric entries (int or all-digits string) before picking one for the outgoing send. If none are numeric, log loudly — better than silent drop. Also adds flush=True to the proactive print lines so log entries appear in real time (bridge log is written via shell redirect, default line-buffered → no flush on print until process exit). Test plan: - allowFrom=[8606877252]: numeric_ids=[8606877252], dispatch ok. - allowFrom=['abhikunte']: numeric_ids=[], log 'skipped: no numeric'. - allowFrom=[8606877252,'abhikunte']: dispatches to 8606877252. - allowFrom=['8606877252']: str-of-digits passes filter, dispatch ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 13, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
pushed a commit
that referenced
this pull request
May 19, 2026
…nichi#794) * feat(screen-companion): skeleton skill (M1) + pair-read-paper config v0 skeleton for the screen-companion skill. Closes nothing yet — this PR ships the *primitive*, not the working feature. Followup PR wires the loaded config into voice-agent.ts. What's in this PR: - skills/screen-companion/SKILL.md — describes the skill, the M1 model (master skill + N declarative configs), how to add a new use case, trust + scope boundary. - skills/screen-companion/configs/pair-read-paper.yaml — the first use-case config. Demonstrates the full schema: activation triggers (voice phrases + button label + CLI alias), vision_mode + cadence, system_prompt_overlay with domain priors + don't-list, tools_allow whitelist, goal_template for optional activation-time fill-in. - skills/screen-companion/scripts/activate.ts — loader + activation printer. v0 scope: discover configs in configs/*.yaml, validate required fields, load one by name, print what *would* happen when it's activated. No voice-agent wiring yet — intentional, so the config schema can crystallize against a real concrete config (pair-read-paper) before the integration shape is committed. CLI: npx tsx skills/screen-companion/scripts/activate.ts --list npx tsx skills/screen-companion/scripts/activate.ts --config pair-read-paper npx tsx skills/screen-companion/scripts/activate.ts --config pair-read-paper --goal "..." Picks M1 ("master skill + declarative configs") from screen_companion.md. Discussion + tradeoffs vs M2 (per-use-case sub-skills) + M3 (SDK) live in that doc. YAML parsing via python3 subprocess (same pattern as src/oc-profile-catalog.ts); avoids adding js-yaml as an npm dep. Next PR (followup): - Wire system_prompt_overlay onto the active VoiceSession. - Restrict the tool surface to tools_allow at activation time. - Hook vision push/pull cadence. - Add pair-debug + pair-review-code configs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(screen-companion): swap v0 config from pair-read-paper to guided-setup Owner-picked 2026-05-17: the v0 first-shipped config is `guided-setup` (proactive + goal-directed — Sutando narrates next steps for a UI config task), not `pair-read-paper`. More visceral demo angle: "Sutando helps me set up a Discord bot I've never configured before" vs. "Sutando reads a paper with me." Changes in this commit: - Add skills/screen-companion/configs/guided-setup.yaml — the new v0 config. PROACTIVE shape: tighter vision cadence (700ms vs 1000ms), prompt overlay narrates next steps continuously, explicit DO NOT click/type guidance (Sutando narrates; user keeps the action). - Drop skills/screen-companion/configs/pair-read-paper.yaml — was shipping as the v0 reference config but the demo angle is weaker; owner-clarified the framing should center on "help me set up something I've never done before." - Update SKILL.md table + run example + roadmap to reflect guided-setup as the shipped config; pair-read-paper dropped (siblings pair-debug + pair-review-code still tracked in screen_companion.md as future). - Update activate.ts doc header + CLI example to use guided-setup. - Add Privacy + data handling section to SKILL.md: default DOES NOT take screenshots, record video, or persist frames; frames are sent to Gemini Live for real-time understanding and discarded; text transcripts are persisted (conversation-server precedent); user has to explicitly opt-in per request to save screenshots/recordings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(screen-companion): wire guided-setup end-to-end via skill manifest Closes the wiring gap left by the v0 skeleton — voice can now activate guided-setup mode directly. Earlier passes shipped the YAML config + CLI loader; this pass adds the missing piece: - manifest.json — skill is enabled, points to tools.ts - tools.ts — exports activate_screen_companion tool (loads config, returns prompt overlay + activation_message + vision hint as a structured payload) - scripts/load-config.ts — shared loader used by both the CLI activate.ts and the inline tool (was duplicated; now single source of truth) - scripts/activate.ts — refactored to import from load-config.ts (-80 lines) - SKILL.md — drops the "v0 skeleton — load + print only" caveat, adds the voice activation phrases + the wire-through explanation Smoke test (matches what voice will see): npx tsx -e "import('./skills/screen-companion/tools.ts').then(...)" → status: 'activated', mode: 'guided-setup', goal/instructions/tools_allow all populated, activation_message ready to speak. Architecture: tool lives in the skill (per CLAUDE.md decision guide — self-contained feature). Voice-agent picks it up via the existing skill manifest loader (same pattern as gws-gmail-voice). No changes to src/inline-tools.ts or src/voice-agent.ts. Voice-agent restart required to register the new tool. Configs themselves are read fresh per activation, so adding more configs in later PRs does not require another restart. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(screen-companion): tolerate LaunchAgent python3 without PyYAML Voice-agent runs under a LaunchAgent whose PATH is `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`. Homebrew's python3 wins that lookup but does not ship PyYAML, so `python3 -c 'import yaml; ...'` in parseYaml() failed every activation with ModuleNotFoundError. The tool returned an error and Gemini fell back to default behavior — silent to the owner aside from "screen companion failed." Try a list of python3 candidates (system → homebrew → PATH default), use the first that can run the yaml import. macOS's `/usr/bin/python3` ships PyYAML, so the system Python is the usual winner under the LaunchAgent PATH. Aggregate per-binary errors into the thrown message for diagnosis when all candidates miss. Verified under the LaunchAgent's PATH env: activation now succeeds. * feat(vision): single-line nudge for screen-companion at share-start The existing screen-share-started system note tells the model to say "I can see your screen now" but stops there — so users who don't already know the activation phrases (e.g. "help me set this up" for guided-setup) share their screen and get no signal that pre-built companion modes exist. Add a one-line, mode-agnostic nudge: "Want me to help you through something?" If they describe a goal that fits any configured mode, the model routes via `activate_screen_companion`. Mode-agnostic on purpose — the prompt does not list configs by name, so it won't age as new ones land (pair-debug, pair-review-code planned per screen_companion.md). The model already knows the catalog from the `activate_screen_companion` tool's description (which discovers configs at boot), so listing them in the nudge would be redundant. * feat(vision): share-start nudge names screen-companion + configs explicitly First attempt (626a18f) shipped a mode-agnostic nudge ("Want me to help you through something?") that did not surface the screen-companion catalog. Owner test 2026-05-17 17:28 PT: routing worked (goal → mode) but discoverability didn't — owner reported "I do not hear the nudge about screen companion mode." Updated injected prompt instructs Gemini to deliver ONE sentence that (a) acknowledges the screen, (b) names "screen-companion mode" explicitly, (c) names the available configs by name (Gemini reads them from the activate_screen_companion tool description, which is discoverConfigs() at boot — so new YAMLs surface automatically), (d) asks the user's goal. Example shape: "I can see your screen — screen-companion mode is available with guided-setup for UI walkthroughs. What are you trying to do?" * fix(screen-companion): force ground-then-step in guided-setup overlay Owner test 2026-05-17 17:34 PT: in a FRESH voice session with no chat history, Gemini activated guided-setup for "set up a Discord bot" and asserted "you're on the Discord Developer Portal" + "I see your cursor hovering over those existing apps" — when the user was demonstrably NOT on the dev portal. Goal-priming overrode actual vision input. (Verified via fresh-session diagnostic — chat-history anchoring was ruled out.) Add a GROUNDING RULE section at the top of the overlay: - Name what you ACTUALLY see (page title/URL/breadcrumb, dominant UI elements, overall page kind) BEFORE naming any step. - If the visible page doesn't match the goal's destination, do NOT assert the user is on the right page — say what you see and ask them to navigate. - Never fabricate UI elements; if a label is illegible, ask the user to read it. - Goal text is a hint about WHERE the user is going, NOT proof of where they ARE. Reconcile goal vs visible-screen as two separate facts every turn. Configs are loaded fresh on each activation — next re-activation picks this up without a voice-agent restart. * docs(screen-companion): downgrade tools_allow claim from 'enforced' to advisory sonichi cold-review on PR sonichi#794 flagged: SKILL.md asserts the tool allow- list is "enforced at activation time; configs cannot grant tools the active VoiceSession doesn't already expose" — but tools.ts:67 just returns the field in the activation payload as a string list. The _note instructs Gemini to self-restrict; there is no path in the bodhi-realtime-agent layer that scopes the live tool surface. Update the Trust + scope section to match reality: advisory in v0, hard enforcement deferred to a follow-up PR. The risk surface ("worst case is Gemini calls a tool you didn't intend") is named explicitly so readers can calibrate. Code unchanged — the field still functions as the documented intent and Gemini does self-restrict in observed sessions; only the doc claim was overstating. * chore(screen-companion): defensive validation + SKILL.md typo (sonichi #5, #6, #8) From sonichi cold-review on PR sonichi#794 — uncontroversial nice-to-haves that don't depend on the scope decisions for findings #1–#3. - #5: validateConfig now checks tools_allow is a string array. A YAML with `tools_allow: "vision_query"` (string instead of list) used to pass validation and crash later at `.length` access with a confusing error. Now fails fast at load with a clear message. - #6: vision_cadence_ms bounded to 100–5000ms. A typo like 70000 (70s) would silently produce useless frame cadence; 50ms would saturate the network. The 100–5000 range covers every plausible mode today. - #8: SKILL.md typo — "bash scripts/activate.ts --list" → "npx tsx ..." (matches the smoke-test block above; bash can't execute the .ts). No behavior change for the live guided-setup config (verified by --list and tsc clean). * docs(screen-companion): mark guided-setup tools_allow as PLANNED (sonichi #1) PR sonichi#794 review finding #1 — tools_allow lists three tools that don't exist anywhere in the codebase (zero grep matches outside this skill). sonichi's three suggested fixes were: rename to real tools, land the missing tools, or document as schema-only. Going with the documentation path for v0 — least-risky of the three, reversible, and consistent with the truthfulness fix in eff1286 that already downgrades the allow-list claim in SKILL.md from "enforced" to "advisory". guided-setup as shipped relies only on push-mode vision frames + Gemini Live's conversational capability — no tool calls are actually required to operate. The PLANNED comment captures design intent without fabricating wiring that doesn't exist; a follow-up PR registers real tools and Gemini gets a real allow-list to follow. Code unchanged. CI green expected — pure YAML comments. * refactor(vision): contributor registry — remove screen-companion knowledge from core (sonichi #3) PR sonichi#794 review finding #3: src/vision-tools.ts:254-256 hardcoded the strings "screen-companion mode" and "guided-setup" in the screen-share- started system note. Per CLAUDE.md decision guide, core services must not contain feature-specific logic; if a user removed the screen- companion skill, vision-tools.ts would lie to Gemini about modes that no longer exist. Refactor: - src/vision-tools.ts now exposes `registerVisionOnContributor(fn)`. When push mode starts, the injected note is built from a generic base note + each contributor's output. With zero contributors registered, the note is generic and mentions no modes. - skills/screen-companion/tools.ts registers a contributor at module- load time. The contributor calls discoverConfigs() at injection time, so new YAMLs in configs/ surface automatically without code changes. - If the skill is disabled (manifest.enabled=false) or removed, the skill-loader at src/inline-tools.ts:937 never imports tools.ts, the contributor never registers, and vision-tools.ts emits only the base note — exactly matches CLAUDE.md "skills are optional." Behavioral equivalence: with screen-companion enabled (default), the injected note now contains the same essential content as before — just sourced from the contributor instead of hardcoded. Verified: tsc clean. Smoke test confirms contributor count goes 0→1 when the skill module is imported. Voice-agent restarted to pick up the new module. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 19, 2026
…ids (#6) PR #4 added @username support to load_allowed() for incoming-auth. The proactive dispatch path still does `next(iter(owner_ids))` and then `int(owner_id)` — when allowFrom contains @username strings, int() raises ValueError and the message is silently dropped along with the file (it gets unlinked in the finally-style cleanup at the end of the block). Observed live 2026-05-13: an amazon-orders proactive notification was written to results/proactive-amazon-orders-{ts}.txt, the bridge claimed it via .sending rename, attempted dispatch, hit ValueError on int('abhikunte'), caught in the except, and unlinked the file without sending. Owner only got the update via direct-API fallback. Fix: filter owner_ids to numeric entries (int or all-digits string) before picking one for the outgoing send. If none are numeric, log loudly — better than silent drop. Also adds flush=True to the proactive print lines so log entries appear in real time (bridge log is written via shell redirect, default line-buffered → no flush on print until process exit). Test plan: - allowFrom=[8606877252]: numeric_ids=[8606877252], dispatch ok. - allowFrom=['abhikunte']: numeric_ids=[], log 'skipped: no numeric'. - allowFrom=[8606877252,'abhikunte']: dispatches to 8606877252. - allowFrom=['8606877252']: str-of-digits passes filter, dispatch ok. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 19, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(github-webhook): move task: field last in task writer
issue/PR bodies and comment text are user-controlled GitHub content —
placing task: before source: allowed a crafted body to forge the source
header. Moves task: last to match the invariant established across all
other task writers in PRs #38–#42.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(security): regression guard for task: field-last invariant
Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.
Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.
Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
…#45) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(health-check): auto-restart voice-agent and web-client when stale Adds --fix handlers for voice-agent and web-client staleness, matching the existing conversation-server pattern. For web-client, kills by LISTEN port (lsof :8080) rather than just pgrep to avoid the child-PID miss that caused the first manual restart attempt to silently fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(github-webhook): move task: field last in task writer
issue/PR bodies and comment text are user-controlled GitHub content —
placing task: before source: allowed a crafted body to forge the source
header. Moves task: last to match the invariant established across all
other task writers in PRs #38–#42.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(security): regression guard for task: field-last invariant
Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.
Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.
Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
…#45) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(health-check): auto-restart voice-agent and web-client when stale Adds --fix handlers for voice-agent and web-client staleness, matching the existing conversation-server pattern. For web-client, kills by LISTEN port (lsof :8080) rather than just pgrep to avoid the child-PID miss that caused the first manual restart attempt to silently fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 24, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 24, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 24, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 24, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 24, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(github-webhook): move task: field last in task writer
issue/PR bodies and comment text are user-controlled GitHub content —
placing task: before source: allowed a crafted body to forge the source
header. Moves task: last to match the invariant established across all
other task writers in PRs #38–#42.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(security): regression guard for task: field-last invariant
Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.
Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.
Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
…#45) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(health-check): auto-restart voice-agent and web-client when stale Adds --fix handlers for voice-agent and web-client staleness, matching the existing conversation-server pattern. For web-client, kills by LISTEN port (lsof :8080) rather than just pgrep to avoid the child-PID miss that caused the first manual restart attempt to silently fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(github-webhook): move task: field last in task writer
issue/PR bodies and comment text are user-controlled GitHub content —
placing task: before source: allowed a crafted body to forge the source
header. Moves task: last to match the invariant established across all
other task writers in PRs #38–#42.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(security): regression guard for task: field-last invariant
Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.
Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.
Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
…#45) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(health-check): auto-restart voice-agent and web-client when stale Adds --fix handlers for voice-agent and web-client staleness, matching the existing conversation-server pattern. For web-client, kills by LISTEN port (lsof :8080) rather than just pgrep to avoid the child-PID miss that caused the first manual restart attempt to silently fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(github-webhook): move task: field last in task writer
issue/PR bodies and comment text are user-controlled GitHub content —
placing task: before source: allowed a crafted body to forge the source
header. Moves task: last to match the invariant established across all
other task writers in PRs #38–#42.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(security): regression guard for task: field-last invariant
Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.
Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.
Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
…#45) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(health-check): auto-restart voice-agent and web-client when stale Adds --fix handlers for voice-agent and web-client staleness, matching the existing conversation-server pattern. For web-client, kills by LISTEN port (lsof :8080) rather than just pgrep to avoid the child-PID miss that caused the first manual restart attempt to silently fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 4, 2026
…#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 4, 2026
…ine (#34) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(task-bridge): archive non-voice results when voice client is offline Upstream sonichi#695 handled task-chat-* archival (no bridge consumer). The residual gap: telegram/discord/api/web tasks still hit the old "stay queued" path and accumulated indefinitely, firing health-check task-queue warnings and filling the web UI with stale "done" rows. Fix: after checking voice-task and chat-task cases, archive remaining non-voice results after a 30s window (two full bridge poll cycles) so their originating bridges (telegram, discord, agent-api) have time to deliver before cleanup. Also adds explicit continue after the voice-task Discord-forward block so voice tasks never fall through to the non-voice archival path on a Discord-forward failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 4, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(disk-cleanup): monthly easy-wins disk reclamation skill
Adds a new skill that runs monthly (1st of the month at 04:17 AM local) and
clears regenerable caches and stale installers. Born from the 2026-05-19
one-shot reclamation that freed 141 GB; the easy-wins portion is the
auto-pilot half — recurring, low-risk, no judgment calls.
What it cleans (all re-create themselves on next use):
- Downloads/*.{dmg,pkg,zip} ≥50 MB and >30 days old
- ~/.npm cache (npm cache clean --force)
- Homebrew cache + stale portable-rubies (brew cleanup -s)
- ~/Library/Caches/com.spotify.client
- ~/Library/Caches/electron
- ~/.gradle/caches
- pip cache (pip3 cache purge)
Notification policy: writes results/proactive-disk-cleanup-{ts}.txt ONLY
when ≥1 GB was reclaimed. Silent on small-reclaim months so it doesn't ping
the owner for noise. Threshold overridable via env DISK_CLEANUP_NOTIFY_THRESHOLD_MB.
Not touched (require human judgment, kept manual on purpose): user data,
photo libraries, X-Plane, Docker VMs, Chrome cache, ~/.gemini, ~/.sutando,
source repos.
Smoke-tested locally after the 141 GB run; still reclaimed an additional
430 MB (pip cache + a few more downloads). Cron added to schedule-crons/
crons.json as `monthly-disk-cleanup`.
Personal-only — not contributable upstream (caches are owner-specific).
PR targets local-main per the rebase model.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 4, 2026
#41) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(bridges): move task: field last in discord + telegram task writers discord-bridge and telegram-bridge both had `task:` as the 3rd field (after id/timestamp), before source, channel_id, access_tier, priority. A user whose message body contains `access_tier: owner` would have that line appear before the bridge-set `access_tier: team`/`other` — any consumer that doesn't stop at `task:` (e.g. simple grep, future parser) would read the forged value. Completes the field-order sweep across all task writers: PR #38: voice-agent + context-drop writers (task-bridge.ts) PR #38: _isVoiceTask + parse_priority stop-at-task: (task-bridge.ts, task_priority.py) PR #40: writeChatTask (task-bridge.ts) This PR: telegram-bridge.py, discord-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 4, 2026
#42) * feat(phone-server): add /twilio/sms webhook for inbound SMS Adds an SMS path to conversation-server.ts that mirrors the existing agent-api SMS handler. Lets us point Twilio's "A message comes in" webhook at the same ngrok tunnel (port 3100) already in use for voice, removing the need for a second ngrok tunnel — the free tier is one tunnel only. Behavior matches /twilio/sms in src/agent-api.py: - Parses From and Body from the urlencoded POST - Writes a tasks/task-{ts}.txt with source=twilio_sms - Replies with TwiML <Message>Got it. Sutando is on it.</Message> Tested locally: POST returns valid TwiML and produces a task file. * feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks Polls tasks/ for files where source=twilio_sms (written by the phone-server's /twilio/sms handler), indexes task_id → from_number, and when results/{task_id}.txt appears, sends the body back to the sender via Twilio Messages API and deletes the result file. Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a small daemon launched by src/startup.sh under section 6b alongside the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are missing. Cleanup: HTTP failures retry until the result file is 5 min old, then the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked phone number). Empty results are dropped immediately. Tested: live inbound SMS from owner number → task file written by phone-server → loop produced result → bridge delivered SMS reply (SID SMe0308b9...). * fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed The previous version treated HTTP 201 from Twilio Messages as success, but 201 just means "enqueued" — carriers can still drop the message later (e.g. A2P 10DLC error 30034 on US long-codes). Owner reported every SMS reply since 22:37 UTC silently dropped: status=undelivered, error_code=30034. After enqueue, the bridge now tracks each SID in an in-flight map and polls /Messages/{sid}.json after 4s. On status in {undelivered, failed} (or stuck in queued/sending after 60s), it drops a proactive- file so the existing telegram-bridge delivers the reply via Telegram instead, prefixed with the failure reason. Backstop: HTTP-level enqueue failures (auth/4xx) also fall back to Telegram once the result is >60s old, instead of looping the retry forever. Net effect: SMS round-trips become channel-agnostic — owner gets the reply on whichever channel works, without losing the body. Once Twilio A2P 10DLC registration lands, all replies will route back to SMS and the fallback path stays dormant. * feat(health-check): cover sms-bridge Adds the new SMS reply bridge to the health-check service iteration alongside telegram-bridge and discord-bridge. Configuration check differs: sms-bridge reads TWILIO_* from workspace .env, not ~/.claude/channels/sms/.{env,access.json}. The check goes ok when the daemon is running, warn when configured-but-not-running, and is silently skipped when the env vars are missing or SKIP_SMS_BRIDGE=1. Without this, a crashed sms-bridge would surface only when the owner notices replies dropping — health-check is the canonical "is anything broken" surface and needs to know about every bridge in 6b of startup.sh. Tested: pkill the bridge → "warn — configured but not running"; restart → "ok — running". * feat(mac-mini-deal-finder): scrape Craigslist for matching listings Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD, ≤$500, local pickup near 94566) and notifies via SMS + Telegram on first sight of each new match. Filters: - Title must mention "mac mini" (rejects body-only mentions which catch too many accessory listings). - Reject accessory keywords: cord, cable, adapter, hub, case, cover, fan, antenna, OEM parts, etc. - Reject Intel-era listings (i3/i5/i7/i9). - Require explicit M-series chip (M2/M3/M4) — no soft match without it. - Soft match for missing RAM/storage when chip is known (sellers often omit specs). Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from .env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks it up). State: state/seen.json dedupes notifications across runs (gitignored). state/criteria.json is the editable config. Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false- positive matches with current filters; the rejection reasons line up with what a human reviewer would write. Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the file is gitignored / personal, so this commit registers the skill but each user enables it in their own crons.json). * feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream), periodic syncs are needed to keep this fork's main current with Chi's improvements. This is the automation: a bash script + daily cron entry that: - Fetches `upstream/main` - Stays silent (exit 0) if nothing new or if the working tree is dirty (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh etc. carry long-running uncommitted state; notifying daily would be noise) - Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin - On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt with the diff summary so Telegram surfaces what came in - On conflict: aborts (leaves main untouched), writes a Telegram notification with the manual-resolution recipe Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak minute, after morning-briefing's 06:57 fire so the morning brief reflects yesterday's main, sync runs before the workday. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): /amazon dashboard + 6h scan skill Scans Gmail for Amazon order emails since 2026-01-01 (explicit history boundary set by owner) and surfaces them at /amazon with current delivery status. Refreshes every 6 hours. Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP access lives in Claude Code, not Python). The cron fires the prompt in scan-prompt.md verbatim; the agent runs Gmail searches against auto-confirm@amazon.com / shipment-tracking@amazon.com / order-update@amazon.com, dedupes by item title, takes the latest status email per order (Ordered → Shipped → Out for delivery → Delivered), writes state/orders.json. Files added: - skills/amazon-orders/SKILL.md — schema + behavior - skills/amazon-orders/scan-prompt.md — verbatim scan instructions - skills/amazon-orders/.gitignore — excludes state/ Files modified: - src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes: GET /amazon (HTML, sortable table grouped by status, summary cards) GET /amazon/data (JSON for client-side polling) POST /amazon/scan (queues out-of-cycle scan via task file) The /amazon page splits orders into 'In progress' (ordered/shipped/ out_for_delivery) and 'Delivered' (collapsible older history). Each table is independently sortable. Summary cards show Total / In progress / Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods / Amazon Fresh. State (orders.json) is gitignored — personal order data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add Price column to /amazon Owner ask: "add price to the /amazon page table" The price isn't in Amazon's email subject snippets — only in the body. Updated scan-prompt.md to instruct future scans to call get_thread on each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing 49 orders, did this manually (parallel get_thread calls) and populated total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm emails don't carry totals — left null for those 7 grocery orders. Special handling: - $0.00 grand totals = paid via gift card / rewards points. Display as '$0 (gift card)' so it's not confusing. - 4 of the orders carry value of ~$108-200 paid by gift card; item subtotals captured in notes. Page changes: - New 'Price' column in both the In-progress and Delivered tables (right-aligned, tabular-nums, ascending → larger first when clicked for first time) - New 'Total spent' summary card showing running total across priced orders (currently $3,215.99 across 42 orders) - Sort defaults: dates and price default to descending; text columns default to ascending Schema updates in SKILL.md: - Added `total_spent: number | null` and `currency: string` fields Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(amazon-orders): add returns_started + refund_issued columns Tracks return lifecycle alongside delivery for each order: - 'Return started' — earliest return-request-confirmed date for the item - 'Refund issued' — latest refund-issued date for the item Schema gains two new fields per order: returns_started_date and refund_issued_date (both nullable). Summary card adds a 'Returns' stat showing N returned with M/N refunded. scan-prompt.md updated with return@amazon.com query + dedupe rules for multi-item bundle returns. SKILL.md schema doc updated. Note: state/orders.json is gitignored (personal data); the in-tree backfill of 4 known returns lives only on this machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4) When onboarding new allowed users, the allowlist check only matched numeric Telegram user IDs. Users typically know their @username (visible in profile) but rarely their numeric ID (requires API call). This made /telegram:access allow @Someone silently no-op when admins added a username — the bridge dropped every message from that user with no indication that the entry was the wrong shape. Now load_allowed() values can be either: legacy numeric ID (still matches sender_id) or @username string (matches username from Telegram update). The dropped-message log line also gains the id for easier debugging. In production via health-check --fix restart since 2026-05-12 per pending-questions.md; this commit formalizes what is already running. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7) Two long-untracked items have been showing up in `git status` every pass for ~4 days, adding noise to every git-aware proactive-loop step. - `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon at $1,650 → owner purchased → decommissioned per build_log entry '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json` has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files on disk in case the owner wants to revive for Black Friday — just don't track or noise up `git status`. - `.last-pq-set`: runtime state written by `src/check-pending-questions.py` to dedupe its hourly notifications. Pure cruft; should never have been a candidate for tracking. Same class as `.last-pq-notify` which is already gitignored above. Top of build_log.md explicitly says both '...likely belong in .gitignore.' Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): persist pending_replies to disk across restarts (#8) `pending_replies` mapped task_id → chat_id only in RAM. Any bridge restart between an incoming Telegram message and its result file being written silently orphaned the reply: the result file lands, the bridge polls, finds no chat_id mapping for the task_id, and either crashes on `int(owner_id)` (proactive path — fixed in PR #6) or sits inert (reply path — this fix). Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question (task-1778697328775) + 'are you awake' ping (task-1778697330159) arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered by owner clicking a health-check popup); 11:56 result files referenced the wiped task_ids → orphaned. Manually drained via direct Telegram API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md. This fix: - New `state/telegram-bridge-pending.json` (auto-gitignored by `state/*` rule). - `_load_pending_replies()` on startup; prunes entries older than 24h to avoid unbounded growth + dispatching very-stale replies. - `_save_pending_replies()` after each add (line ~344) and pop (~406). Atomic write via .tmp + rename to avoid partial-file corruption. - Preserves `added_at` across re-saves rather than rewriting with now() (which would defeat the age-based pruning). - Silent on disk failure — persistence is defensive, not a correctness guarantee, so a transient ENOSPC shouldn't crash the bridge. Trigger for this class of restart is mostly eliminated by PRs #4 + #5 (the dirty-WT diffs that made health-check --fix think the bridge was stale), so this is a backstop for the remaining low-frequency restart paths: manual restart, system reboot, process crash, future-code-edit stale flag, etc. Test plan (validated via inline round-trip): - Save → load round-trips the chat_id mapping (int preserved). - Re-save does not bump `added_at` on entries already on disk. - Entries older than 24h dropped on load. - Malformed entries (chat_id not int) dropped on load. - Atomic-replace: no .tmp leak on success. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(telegram-bridge): regression suite for pending_replies persistence (#9) Locks in the contract added in PR #8. Without these tests, a future refactor of the persistence helpers could silently re-introduce the data-loss window that hit 2026-05-13 11:35–11:56 PT. Mirrors the style of tests/discord-bridge-state-prefetch.test.py (standalone runner, exit 0 on pass, no pytest dependency). 10 cases: - Round-trip preserves chat_id as int. - Re-save does NOT bump added_at on entries already on disk (otherwise the 24h age-prune is defeated by writer activity). - Aged-out entries (added_at > 24h ago) dropped on load. - Malformed chat_id (string) dropped on load. - Missing added_at treated as age 0 → dropped (defensive). - Non-dict legacy entries (bare-int schema) dropped on load. - Atomic write via .tmp leaves no leftover on success. - Load from missing file returns {} (no crash). - Load from corrupted JSON returns {} (defensive). - Save survives transient OSError silently. Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): stale-detect by content hash, not commit-time (#10) The commit-time-based logic had a hole: an upstream merge that brings new file content via commits with timestamps PREDATING proc_start would incorrectly suppress the stale flag. Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93) added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent process started 11:51 PT. Old check: commit_time (11:10) < proc_start (11:51) → returns True → voice-agent stayed silently 'ok' while running pre-merge code. With this fix, the same check correctly flags voice-agent stale (verified by running the patched health-check locally — see test_mtime_bumped_content_changed_returns_false). New strategy: 1. Fast path: if src_mtime <= proc_start, file was never touched → trivially unchanged. Returns True. 2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of proc_start, then `git show <old_head>:<path>` and byte-compare against current disk content. Equal → idempotent mtime bump (the git-checkout no-op case PR sonichi#253 originally targeted). Different → real stale. 3. Fail-safe: any git error, reflog gap, or missing file → returns False (assume stale). Better to over-flag and force one restart than miss a real stale deploy. Test suite: tests/health-check-stale-detect.test.py — 6 cases covering all branches. Locks the new contract in place. Mirrors the standalone-unittest style of the discord-bridge test suite. All pre-existing tests continue to pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): skip bridges whose env value is obviously a placeholder (#11) Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in ~/.claude/channels/discord/.env for 4 days. After being sidelined to .env.placeholder this morning, something on the system recreated the placeholder file 7 hours later (md5-identical content). Health-check saw .env existing and kept firing 'configured but not running' alerts hourly because file-existence was the only configuration signal. This patch adds a length-threshold check: a channel's .env is treated as 'really configured' only if at least one *_TOKEN key has a value >= 30 chars. Real shapes: - Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...') - Discord bot tokens: ~70 chars (Base64-encoded payload) - 30-char floor is well below both — never rejects a real credential. The check applies only to channels whose configuration is via .env (currently telegram + discord). access.json-only configs aren't affected (no token to inspect). Surrounding-quote and whitespace stripping match how python-dotenv loads values; commented lines (# prefix) are ignored. Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11 cases covering all edge paths. The contract is replicated as a top-level helper in the test file (manual sync required if the inline logic changes). Refactor to a shared helper deferred for now; the check is small enough that duplicating it is cheaper than plumbing a shared import. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12) scripts/start-cli.sh launches the sutando-core tmux session with a custom socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't pass the matching -S flag, so its capture-pane call hit the DEFAULT socket at /private/tmp/tmux-501/default — empty — and silently emitted 'no such file' errors once per minute since the feature shipped. Result: the defensive fallback in effectiveAgentState() that's supposed to scrape the tmux pane when core-status.json is stale was effectively dead code (returned 'idle' on every call). Caught today via log audit: 28 [tmux-status] capture failed lines in 2h of web-client.log. Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock, matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass '-S <socket>' before the capture-pane subcommand. Verified post-fix: $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5 ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking) ❯ ────────────────────────────── sutando-core ── Now returns real pane content instead of the empty-socket error path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13) New skill: skills/karts-air/, modeled on amazon-orders + deal-finder. Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA airframes matching the owner's executive buyer profile (mountain-mission payload math, CAPS currency, 3-branch avionics evaluation, 5 standing questions per candidate). ## Files - skills/karts-air/SKILL.md — skill overview, architecture rationale, cron entry, notification format spec. - skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to the agent. Source list, coarse filter, per-candidate LLM evaluation, schema for state/karts-air-data.json, when to bail out. - skills/karts-air/state/criteria.md — owner-editable narrative buyer profile (mission, payload, 3 avionics branches, 5 questions, formatting). This is the source of truth — agent reads it verbatim. - skills/karts-air/state/criteria.json — machine-readable filter shorthand (model, year range, price range, source URL list). Coarse pre-filter before LLM eval. - skills/karts-air/.gitignore — karts-air-data.json + history snapshots + credentials are gitignored (per-user financial / buy-side data). ## Web - src/web-client.ts new routes: - GET /KARTS-AIR — live HTML render from state/karts-air-data.json. Shows candidates table (tail, year, price, times, location, branch, CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5), new/price-changed highlights, source-status badges. - GET /KARTS-AIR/data — same data as JSON for programmatic access. - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level 127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path not inlined to avoid header-injection class issues). ## Architecture choice — agent-driven, not Python-scraper-driven Unlike deal-finder which uses bespoke regex parsers for Craigslist's stable static HTML, aviation sites are heterogeneous (JS-rendered, Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS return generic landing pages without a browser UA; controller returns a 3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python parsers, this skill follows the amazon-orders pattern — the agent uses WebFetch at scan-time with a structured-extraction prompt. Owner can grant cookies for paywalled sites to unlock deeper fields later; deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that. ## Cron entry (gitignored crons.json) ```json { "name": "karts-air-scan", "cron": "0 5 * * *", "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..." } ``` Full entry inlined in SKILL.md for fresh-clone discoverability per Chi PR sonichi#651 note 1. Manual invocation: /karts-air (matches existing skill conventions). Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with empty-state render, /KARTS-AIR/data returns the default JSON shell. First real candidates arrive after tomorrow's 05:00 PT cron OR an immediate manual scan via the 'Scan now' button. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent-api): write owner-activity on POST /task (#14) Mirrors what src/telegram-bridge.py:write_owner_activity already does for incoming Telegram messages, so the proactive-loop's active-engagement skip rule (b) recognises web/mobile/CLI task POSTs through this API, not just Telegram and Discord events. The proactive-loop's skip rule (b) reads state/last-owner-activity.json to decide whether the owner is in a conversation right now. Without this fix, agent-api accepts a POST /task, writes the task file for the watcher, returns 200 — and never touches the activity file. The proactive-loop firing 30s later sees the activity timestamp from whenever Telegram last fired and concludes 'owner inactive for hours,' even when they just sent a task via the web /chat UI. Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the chat-like UI' via web — was reflected in tasks/ within ~1 second, but state/last-owner-activity.json kept showing 'Are you awake' from 11:35 PT (the previous Telegram event). Manually patched the activity file at the time so this pass's skip-rule check worked. This commit makes the manual patch unnecessary. - Add `write_owner_activity(channel, summary)` helper in agent-api.py. Atomic .tmp+rename write to state/last-owner-activity.json, same schema as telegram-bridge (`ts`, `channel`, `summary` truncated to 80 chars). Silent on disk failure — activity tracking is a heuristic, not load-bearing. - Call it from POST /task right after the task file is written. - Channel field uses `from` from the task body (e.g. 'web', 'mobile', 'agent-2') so the proactive-loop can later distinguish owner-UI events from agent-to-agent automation. End-to-end test: before ts: 1778723092 (10 min ago) POST /task with {"from":"test-verify-bug-fix","task":"..."} after ts: 1778723803 (just now) channel: test-verify-bug-fix summary: verify owner-activity writes on POST /task `py_compile` passes. agent-api restarted; `GET /status` returns 200. discord-bridge.py likely has the same shape and may need the same fix. Skipped: discord-bridge isn't currently running in this deployment (its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap) so a discord-bridge change wouldn't move the needle today. Worth a follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(karts-air): route case-insensitively so /karts-air also resolves (#15) Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it just shows the Sutando Web UI' when navigating to localhost:8080/karts-air (lowercase). HTTP paths are case-sensitive in node:http's matching, so the route I added at /KARTS-AIR ignored lowercase and mixed-case requests, which fell through to the default dashboard handler at the bottom of the request handler. Fix: normalize url.pathname.toUpperCase() once at the top of the karts-air block, then compare against the uppercase constants. All three routes (page, data, scan) now accept any casing — /KARTS-AIR, /karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler. Verified post-restart with 4 casings: /KARTS-AIR → 4882 bytes (karts-air page) /karts-air → 4882 bytes (same) /Karts-Air → 4882 bytes (same) /KARTS-air → 4882 bytes (same) Pre-fix only the first served the karts-air page; the others returned the 119KB default dashboard. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web-client): write owner-activity on /scan routes (#16) The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan, /amazon/scan) all write owner-tier task files to tasks/ but never bumped state/last-owner-activity.json. This is the same gap PR #14 fixed in agent-api.py for the POST /task endpoint — without it, the proactive-loop's active-engagement skip rule (b) treats the owner as inactive when they've just clicked "Scan now" in the UI, which can trigger spurious pivot announcements in #bot2bot. Adds a writeOwnerActivity() helper that mirrors the Python helpers in agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort — never fail the route on activity-recording errors). Calls it from all three /scan routes with channel labels web-karts-air / web-paidsubscriptions / web-amazon. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): distinguish pgrep probe failure from "not running" (#17) The sutando-app and bridge checks in health-check.py used bare `except:` clauses that collapsed two distinct conditions: - pgrep returncode 1 (genuine "no matches found") - subprocess.SubprocessError / OSError / unexpected returncode (probe failed) Both ended up reported as "warn: not running", which then triggered a self-fired task asking the agent to restart the service or DM the owner. At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep itself failed transiently. False alarm; took the agent off the queue to investigate. Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout. Catch only `(SubprocessError, OSError)`. On probe failure, surface as status="ok" with detail "probe failed transiently — assuming alive" so emit_task_for_failures (which only acts on warn/down/missing/etc.) skips it. A persistent real failure will still surface — pgrep doesn't fail twice in a row in normal operation. Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't stall the whole check past one tick. Tested: existing 26 health-check tests pass. Manual smoke: `python3 src/health-check.py` reports sutando-app as ok (PID 13536 detected correctly). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): distinguish missing access.json from real read errors (#18) load_allowed() used a bare `except:` that collapsed three distinct conditions into one silent "deny all" outcome: - FileNotFoundError (uninitialized — legitimate, no pairing yet) - JSONDecodeError / OSError / ValueError (real fault — should be loud) - any other unexpected error (should propagate) A transient OS error on ~/.claude/channels/telegram/access.json (corrupt write mid-flight, permission flip, brief disk error) would silently reject every incoming Telegram message while the bridge process stayed alive and kept passing the watchdog's process-alive check. Owner-facing symptom: messages just stop arriving, no signal why. Class A failure — silent wrong-state assertion masquerading as security default. Same shape as PR #17's health-check bug, found by sweep for bare-except. Fix: - FileNotFoundError → silent empty set (correct for pre-pairing state). - (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to stdout (lands in logs/telegram-bridge.log) including exception type and path so owner can debug. - Other exceptions propagate. Security default unchanged: empty set = deny all, which is the right fail-closed posture. Only the visibility of the failure changes. Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases (valid config, missing file, corrupt JSON, missing key, permission denied). Pattern follows repo's duplicate-logic convention with sync comment per tests/health-check-skip-placeholder-bridge.test.py. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19) Same bare-except silent-failure pattern as PR #17 (health-check) and PR #18 (telegram-bridge), applied to the three access.json loaders in discord-bridge.py: - load_allowed (line 380) - load_policy (line 387) - load_channel_config (line 394) All three previously collapsed (missing-file | corrupt-JSON | OS-error | permission-error) into one silent default outcome. A transient OS error or corrupt write would silently reject every DM or treat every channel as unconfigured while the bridge stayed alive. Fix differentiates the three cases: - FileNotFoundError → silent default (pre-pairing legitimate state) - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default - other exceptions propagate Security default unchanged: empty allowlist / "pairing" policy / null channel config are still the fallback. Only error visibility changes. Discord-bridge isn't running today (placeholder token — see reference_health_check_placeholder_trap memory), so this fix lands ahead of need; it'll activate when owner enables Discord. Worth shipping now: same code-pattern in two parallel bridges should have the same correctness contract, and a deferred fix tends to drift. Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering all three loaders' valid / missing-file / corrupt-JSON paths plus edge cases (channel=true open form, missing dmPolicy key, etc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20) After PR #13 shipped the karts-air skill with a WebFetch-only data path, the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4 sources. PR investigation confirmed the blocker is fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering), not layout fragility — even headless Chromium via src/browser.mjs gets filtered. Microsoft's @playwright/mcp@latest was installed via `claude mcp add` at 19:35 PT (owner request). Real Chromium with full residential fingerprint defeats the bot detection that blocks WebFetch + curl + bare-headless. This update gives the scan-prompt a concrete two-tier recipe so the post-restart session knows exactly which tool calls to make. Updates: - "Tooling: choose tier per source" — explains when to use WebFetch (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium). - Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot → console_messages diagnosis → evaluate fallback for DOM extraction → close per source. - First-run Chromium download warning — ~150 MB on cold start, with a 60s bail-out rule so cron-triggered scans don't hang. - Per-source status taxonomy clarified: blocked vs blocked-even-with- playwright distinguishes "we didn't try harder" from "we tried as hard as we can and still got blocked". - Bail-out section updated to require BOTH tiers blocked before the honest-fail notification. Owner is about to restart Claude Code to pick up mcp__playwright__* tools. This pre-baked recipe means the post-restart session can run the scan immediately without re-discovering tool names. Companion memory `reference_karts_air_playwright_handoff.md` lists the full Playwright MCP tool inventory. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(karts-air): update SKILL.md sources table with bot-detection reality (#21) The Sources table claimed "Free" access for all 4 aviation classifieds. Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare curl get 403 across the board because all 4 sites use Cloudflare or Datadome fingerprint-grade bot detection (TLS handshake + JS challenges + IP-type filtering, not just UA). A fresh reader of SKILL.md would assume the data is grab-able with basic HTTP and not understand why the live state shows all blocked. Updates: - Sources table column "Access" → split into "Public data" (what's free-to-read once you GET the page) and "Bot detection (verified 2026-05-13)" (what actually happens when an agent tries to GET it) - Architecture section: WebFetch → Playwright MCP fallback now explicitly documented as the two-tier approach (companion to PR #20 which added the same to scan-prompt.md) - Rationale for not using a deterministic Python scraper expanded: not just layout heterogeneity, also the bot detection that defeats Python `requests` without a residential Chromium runtime No code change. Just doc/reality alignment. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(telegram-bridge): gate result archival on delivery success + retry cap (#22) Previously `send_reply()` was a void function that called api("sendMessage", ...) for each chunk and send_file() for each attachment, without inspecting the return values. The result-delivery loop in main() archived the result+task files unconditionally afterwards. Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or network blipped mid-poll, api() returned {"ok": False} silently, send_reply returned normally, and the result file got archived → owner's reply silently lost. Class A failure same shape as PRs #17/#18: error-swallowing masking real failure. The fix has two parts: 1. send_reply() now returns True iff every api() / send_file() call returned {"ok": True}. Exceptions from those calls are caught and counted as failure. We don't short-circuit on first failure within a single send — we still try every chunk so partial delivery happens when possible. 2. The delivery loop in main() now: - Peeks chat_id from pending_replies (no pop until success). - Increments a per-task in-memory attempt counter. - On True → pop, archive, clear counter. - On False AND attempts < cap (10) → leave files for next iteration. - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N] marker, pop, archive. Prevents poison-pill loops on persistent failures (chat blocked, auth revoked). The attempt counter resets on bridge restart, which is intentional: a restart is a natural retry boundary, and bounding poison-pill loops is the only job of this counter. Pending_replies persistence (PR #8) means the task→chat_id mapping survives restarts; this counter doesn't need to. Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive- immediately behavior — those tags indicate "already replied directly, don't try." Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering happy path, ok=False, non-dict response, exception, chunked send (all ok / middle fails), file send (ok / fails), text+file partial failure, blocked-file-path notice. Pattern follows the repo's duplicate-logic convention. Loop-level retry/cap behavior is small enough to verify by inspection (file-IO + dict-mutation, no pure-function seam). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrate): stop claiming success when cp / brew silently failed (#24) Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23). migrate.sh had two sites where a "✓ X" echo fired unconditionally after operations that could silently fail: 1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r channels`, `cp -r skills` all swallowed errors via `2>/dev/null`, then `echo "✓ claude config (settings, channels, skills)"` fired unconditionally. If any/all failed (permission flip, broken symlink pre-empting `cp -r`, source missing), the bundle was shipped with missing config and the destination machine booted half-broken with no signal back to the operator. 2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg python3 2>/dev/null` swallowed all errors (no network, sudo prompt, conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"` claimed success unconditionally. Destination proceeded to Step 3+ with a half-installed system; later steps (watch-tasks, audio processing) failed for non-obvious reasons. Both fixed: - **Claude config:** track each `cp` via target-existence (not exit code — `cp -r` returns non-zero on a single broken symlink like `~/.claude/skills/whatsapp` here, even when 45 of 46 entries copied fine). Echo lists which parts actually arrived (`"✓ claude config (settings, channels, skills)"`) or warns loudly if none did. - **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's output through `tail -3` for the operator to see, then emit `✓` or `⚠ retry manually: brew install fswatch ffmpeg python3` based on the actual return code. Manual smoke: all three branches of the claude-config detector verified (all present → list all; partial → list partial; none → warn). Bash syntax checks pass. Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate their echo on `&&`, or emit `⚠` on failure already (e.g. line 188 `npm install ... || echo " ⚠ ..."`), or are correct defensive checks (file-existence probes). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27) The karts-air Cirrus SR22 deal-hunter is a personal acquisition task for the owner, not applicable to upstream Sutando users. Moving it to the user-level skills dir (~/.claude/skills/karts-air/) keeps it out of both upstream (sonichi/sutando) and the public fork (amkunte/sutando) while preserving the cron + skill discovery patterns that already work for other personal skills like macos-tools. Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(upstream-sync): split main (mirror) from local-main (deployment) (#28) Two-branch rebase model: amkunte/main mirrors upstream/main EXACTLY (clean, contributable) amkunte/local-main deployment branch — personal/WIP commits, rebased on main manually by owner when ready to deploy upstream changes sync.sh now does only the easy half — fast-forward `main` from `upstream/main` and push. Defensively checks that `main` has no local commits (rebase model invariant) and aborts loudly if violated. Never touches `local-main`. Remembers and restores the starting branch so the cron is safe to run while owner is on `local-main` (the common case). Owner brings upstream changes into deployment manually: git checkout local-main && git rebase main && git push --force-with-lease Why this split: Chi rebases PRs before merging upstream, so add/add conflicts on the same file are common when merging upstream into a deployment branch that has its own history. The old script accumulated 4+ conflicts/sync and required owner intervention. New model concentrates conflict resolution at the manual rebase step where owner can resolve per-commit, surgically. SKILL.md updated to document the new model and the manual rebase step. * fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30) src/sms-bridge.py:40 had: WORKSPACE = Path(__file__).resolve().parents[1] This is the historic anti-pattern that src/workspace_default.py docstring explicitly calls out — it resolves to the repo dir (~/sutando/) rather than the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled repo/tasks for inbound twilio_sms tasks while phone-server (correctly) writes them to workspace/tasks, so every real SMS task the phone-server produces was dropped on the floor. Same for results/ and the heartbeat file (health-check looks at workspace, sms-bridge wrote to repo, hence the persistent "heartbeat stale" warn even after restart). Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for .env loading, since .env is code-adjacent config, not runtime state. Mirrors the pattern in src/telegram-bridge.py and every other bridge. Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge goes from "running but heartbeat stale 19000s" to ok. * fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33) notify() was writing relative to \$REPO_DIR; Telegram bridge polls \$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications were silently dropped and never delivered to the owner. Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or ~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-api,slack-bridge): move task: field last in all task writers agent-api.py had three writers (phone call, SMS, voicemail) and slack-bridge.py had one writer, all with task: as the 3rd field before source, from/channel_id, access_tier, priority. For slack-bridge specifically: a Slack user sending `access_tier: owner` in their message body would have that line appear before the bridge-set `access_tier: team`/`other`, potentially bypassing the sandboxed-agent enforcement. Completes the field-order sweep across every task writer in the repo: PR #38: voice + context-drop (task-bridge.ts), _isVoiceTask + parse_priority PR #40: writeChatTask (task-bridge.ts) PR #41: telegram-bridge.py, discord-bridge.py This PR: agent-api.py (×3), slack-bridge.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte
added a commit
that referenced
this pull request
Jun 4, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.
Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>
Tested locally: POST returns valid TwiML and produces a task file.
* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.
Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.
Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.
Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.
After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.
Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.
Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
* feat(health-check): cover sms-bridge
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.
Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.
Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
* feat(mac-mini-deal-finder): scrape Craigslist for matching listings
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.
Filters:
- Title must mention "mac mini" (rejects body-only mentions which
catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
omit specs).
Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).
State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.
Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.
Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main
After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:
- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
(dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
the manual-resolution recipe
Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): /amazon dashboard + 6h scan skill
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.
Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.
Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/
Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
GET /amazon (HTML, sortable table grouped by status, summary cards)
GET /amazon/data (JSON for client-side polling)
POST /amazon/scan (queues out-of-cycle scan via task file)
The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.
State (orders.json) is gitignored — personal order data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add Price column to /amazon
Owner ask: "add price to the /amazon page table"
The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.
Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
subtotals captured in notes.
Page changes:
- New 'Price' column in both the In-progress and Delivered tables
(right-aligned, tabular-nums, ascending → larger first when clicked
for first time)
- New 'Total spent' summary card showing running total across priced
orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
default to ascending
Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(amazon-orders): add returns_started + refund_issued columns
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item
Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.
scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.
Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)
When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.
Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.
In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)
Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.
- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
at $1,650 → owner purchased → decommissioned per build_log entry
'2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
on disk in case the owner wants to revive for Black Friday — just don't
track or noise up `git status`.
- `.last-pq-set`: runtime state written by
`src/check-pending-questions.py` to dedupe its hourly notifications.
Pure cruft; should never have been a candidate for tracking. Same class
as `.last-pq-notify` which is already gitignored above.
Top of build_log.md explicitly says both '...likely belong in .gitignore.'
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)
`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).
Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.
This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
`state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
guarantee, so a transient ENOSPC shouldn't crash the bridge.
Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.
Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(telegram-bridge): regression suite for pending_replies persistence (#9)
Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.
Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).
10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.
Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): stale-detect by content hash, not commit-time (#10)
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.
Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).
New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
proc_start, then `git show <old_head>:<path>` and byte-compare
against current disk content. Equal → idempotent mtime bump (the
git-checkout no-op case PR sonichi#253 originally targeted). Different →
real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
False (assume stale). Better to over-flag and force one restart
than miss a real stale deploy.
Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.
All pre-existing tests continue to pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)
Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.
This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.
The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).
Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.
Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)
scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.
Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.
Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.
Verified post-fix:
$ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
❯
────────────────────────────── sutando-core ──
Now returns real pane content instead of the empty-socket error path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)
New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).
## Files
- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
the agent. Source list, coarse filter, per-candidate LLM evaluation,
schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
profile (mission, payload, 3 avionics branches, 5 questions, formatting).
This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
(model, year range, price range, source URL list). Coarse pre-filter
before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
credentials are gitignored (per-user financial / buy-side data).
## Web
- src/web-client.ts new routes:
- GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
Shows candidates table (tail, year, price, times, location, branch,
CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
new/price-changed highlights, source-status badges.
- GET /KARTS-AIR/data — same data as JSON for programmatic access.
- POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
not inlined to avoid header-injection class issues).
## Architecture choice — agent-driven, not Python-scraper-driven
Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.
## Cron entry (gitignored crons.json)
```json
{
"name": "karts-air-scan",
"cron": "0 5 * * *",
"prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```
Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.
Manual invocation: /karts-air (matches existing skill conventions).
Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-api): write owner-activity on POST /task (#14)
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.
The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.
Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.
- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
Atomic .tmp+rename write to state/last-owner-activity.json, same
schema as telegram-bridge (`ts`, `channel`, `summary` truncated
to 80 chars). Silent on disk failure — activity tracking is a
heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
'mobile', 'agent-2') so the proactive-loop can later distinguish
owner-UI events from agent-to-agent automation.
End-to-end test:
before ts: 1778723092 (10 min ago)
POST /task with {"from":"test-verify-bug-fix","task":"..."}
after ts: 1778723803 (just now)
channel: test-verify-bug-fix
summary: verify owner-activity writes on POST /task
`py_compile` passes. agent-api restarted; `GET /status` returns 200.
discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)
Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.
Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.
Verified post-restart with 4 casings:
/KARTS-AIR → 4882 bytes (karts-air page)
/karts-air → 4882 bytes (same)
/Karts-Air → 4882 bytes (same)
/KARTS-air → 4882 bytes (same)
Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web-client): write owner-activity on /scan routes (#16)
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.
Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(health-check): distinguish pgrep probe failure from "not running" (#17)
The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
- pgrep returncode 1 (genuine "no matches found")
- subprocess.SubprocessError / OSError / unexpected returncode (probe failed)
Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.
At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.
Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.
Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.
Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)
load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
- FileNotFoundError (uninitialized — legitimate, no pairing yet)
- JSONDecodeError / OSError / ValueError (real fault — should be loud)
- any other unexpected error (should propagate)
A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.
Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
stdout (lands in logs/telegram-bridge.log) including exception type
and path so owner can debug.
- Other exceptions propagate.
Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.
Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)
Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
- load_allowed (line 380)
- load_policy (line 387)
- load_channel_config (line 394)
All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.
Fix differentiates the three cases:
- FileNotFoundError → silent default (pre-pairing legitimate state)
- (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
- other exceptions propagate
Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.
Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.
Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.
Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.
Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
(Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
console_messages diagnosis → evaluate fallback for DOM extraction →
close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
playwright distinguishes "we didn't try harder" from "we tried as
hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
honest-fail notification.
Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)
The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).
A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.
Updates:
- Sources table column "Access" → split into "Public data" (what's
free-to-read once you GET the page) and "Bot detection (verified
2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
explicitly documented as the two-tier approach (companion to PR #20
which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
not just layout heterogeneity, also the bot detection that defeats
Python `requests` without a residential Chromium runtime
No code change. Just doc/reality alignment.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)
Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.
Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.
The fix has two parts:
1. send_reply() now returns True iff every api() / send_file() call
returned {"ok": True}. Exceptions from those calls are caught and
counted as failure. We don't short-circuit on first failure within a
single send — we still try every chunk so partial delivery happens
when possible.
2. The delivery loop in main() now:
- Peeks chat_id from pending_replies (no pop until success).
- Increments a per-task in-memory attempt counter.
- On True → pop, archive, clear counter.
- On False AND attempts < cap (10) → leave files for next iteration.
- On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
marker, pop, archive. Prevents poison-pill loops on persistent
failures (chat blocked, auth revoked).
The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.
Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."
Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrate): stop claiming success when cp / brew silently failed (#24)
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:
1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
then `echo "✓ claude config (settings, channels, skills)"` fired
unconditionally. If any/all failed (permission flip, broken symlink
pre-empting `cp -r`, source missing), the bundle was shipped with
missing config and the destination machine booted half-broken with
no signal back to the operator.
2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
claimed success unconditionally. Destination proceeded to Step 3+
with a half-installed system; later steps (watch-tasks, audio
processing) failed for non-obvious reasons.
Both fixed:
- **Claude config:** track each `cp` via target-existence (not exit
code — `cp -r` returns non-zero on a single broken symlink like
`~/.claude/skills/whatsapp` here, even when 45 of 46 entries
copied fine). Echo lists which parts actually arrived
(`"✓ claude config (settings, channels, skills)"`) or warns
loudly if none did.
- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
output through `tail -3` for the operator to see, then emit `✓` or
`⚠ retry manually: brew install fswatch ffmpeg python3` based on
the actual return code.
Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.
Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo " ⚠ ..."`), or are correct defensive checks
(file-existence probes).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)
The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.
Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)
Two-branch rebase model:
amkunte/main mirrors upstream/main EXACTLY (clean, contributable)
amkunte/local-main deployment branch — personal/WIP commits, rebased on main
manually by owner when ready to deploy upstream changes
sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).
Owner brings upstream changes into deployment manually:
git checkout local-main && git rebase main && git push --force-with-lease
Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.
SKILL.md updated to document the new model and the manual rebase step.
* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)
src/sms-bridge.py:40 had:
WORKSPACE = Path(__file__).resolve().parents[1]
This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).
Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.
Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)
notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.
Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(github-webhook): move task: field last in task writer
issue/PR bodies and comment text are user-controlled GitHub content —
placing task: before source: allowed a crafted body to forge the source
header. Moves task: last to match the invariant established across all
other task writers in PRs #38–#42.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Regression introduced by PR #4
PR #4 (
fix(telegram-bridge): accept @username in allowlist alongside numeric ID) updatedload_allowed()to accept@usernamestrings as well as numeric Telegram IDs. The incoming auth check was updated; the outgoing proactive-dispatch path was not.Result: when
~/.claude/channels/telegram/access.jsoncontains only@usernameentries (or even a mix where set iteration happens to pick the string first), the proactive dispatcher doesint("abhikunte")→ValueError→ caught silently → message file unlinked without sending.Observed live
2026-05-13 ~12:44 PT. The amazon-orders cron wrote
results/proactive-amazon-orders-1778701468.txt(new MRM Veggie Elite order). Bridge claimed the file via.sendingrename, attempted dispatch, hitValueErroronint('abhikunte'), caught in theexcept, and unlinked the file without sending. Owner only got the update via direct-API fallback I ran in parallel.Fix
Filter
owner_idsto numeric entries before picking one for the outgoing send. If none are numeric, log loudly ([proactive] skipped: allowFrom has no numeric chat_id) — better than silent drop.Set iter order is implementation-defined, so we can't rely on insertion order in
access.jsonto land on the int — explicit filter is the only correct approach.Also adds
flush=Trueto the proactive print lines so log entries appear in real time (bridge log is written via shell redirect, default line-buffered → no flush on print until process exit, which is how this bug was hidden in logs).Test plan
allowFromvalue[8606877252]numeric_ids=[8606877252], dispatch ok['abhikunte']numeric_ids=[], logskipped: no numeric[8606877252, 'abhikunte']8606877252regardless of set-iter order['8606877252']🤖 Generated with Claude Code