Repository navigation
fix(telegram-bridge): gate result archival on delivery success + retry cap - #22
Merged
Merged
Conversation
…y cap
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>
This was referenced May 14, 2026
amkunte
added a commit
that referenced
this pull request
May 14, 2026
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>
amkunte
added a commit
that referenced
this pull request
May 19, 2026
…y 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>
amkunte
added a commit
that referenced
this pull request
May 19, 2026
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>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
…y 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>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
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>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
…y 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>
amkunte
added a commit
that referenced
this pull request
May 22, 2026
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>
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
…y 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>
amkunte
added a commit
that referenced
this pull request
May 23, 2026
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>
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
…y 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>
amkunte
added a commit
that referenced
this pull request
May 24, 2026
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>
amkunte
added a commit
that referenced
this pull request
May 29, 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 29, 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 29, 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 29, 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 30, 2026
…y 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>
amkunte
added a commit
that referenced
this pull request
May 30, 2026
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>
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
…y 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>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
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>
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
…y 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>
amkunte
added a commit
that referenced
this pull request
Jun 1, 2026
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>
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>
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.
Background
Class-A silent-failure bug in result delivery. The void
send_reply()plus unconditional-archive inmain()'s result loop meant that any transient Telegram failure (429 rate limit, 502 gateway, network blip) silently lost the owner's reply — file got archived even thoughsendMessagereturned{\"ok\": False}.Same bug class as PR #17 (health-check) and PR #18 (telegram-bridge load_allowed): error-swallowing that pretends success. The owner-facing impact is high — replies just don't arrive, and the archive contains the reply text so a forensic check finds it but the user never sees it.
Fix
Two parts:
send_reply()returnsbool. True iff everyapi()andsend_file()call returned{\"ok\": True}and didn't raise. We don't short-circuit on first failure within a send — every chunk is still attempted so partial-delivery happens when possible.Delivery loop in
main():chat_idfrompending_replies(no pop until success).[DELIVERY-FAILED:N]marker, pop, archive. Prevents poison-pill loops when the failure is permanent (chat blocked, auth gone).Sentinel-skip paths (
[no-send]/[REPLIED]) keep their original archive-immediately behavior — those tags mean "already replied directly elsewhere."Why in-memory counter (not persisted)?
A restart is a natural retry boundary.
pending_repliesis persisted (PR #8) so the task→chat mapping survives. The counter doesn't need to — if the bridge restarts mid-failure, starting fresh is correct.Tests
tests/telegram-bridge-send-reply-bool.test.py— 10 cases on thesend_replybool contract:ok: False, non-dict response, exceptionLoop-level retry/cap behavior verified by inspection (file-IO + dict mutation, no pure-function seam clean enough for unit test).
Diff
+387/-24 across 2 files:
src/telegram-bridge.py+ newtests/telegram-bridge-send-reply-bool.test.py.Restart consideration
Running bridge (PID 97751) on pre-#22 code. New behavior activates on next restart. Until then: same silent-loss behavior as before this PR (no regression). Owner's natural restart for Playwright MCP pickup will also activate this fix.
🤖 Generated with Claude Code