Skip to content

fix(telegram-bridge): gate result archival on delivery success + retry cap - #22

Merged
amkunte merged 1 commit into
mainfrom
fix/telegram-bridge-send-reply-retry-cap
May 14, 2026
Merged

amkunte merged 1 commit into
mainfrom
fix/telegram-bridge-send-reply-retry-cap

Conversation

@amkunte

@amkunte amkunte commented May 14, 2026

Copy link
Copy Markdown
Owner

Background

Class-A silent-failure bug in result delivery. The void send_reply() plus unconditional-archive in main()'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 though sendMessage returned {\"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:

  1. send_reply() returns bool. True iff every api() and send_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.

  2. Delivery loop in main():

    • Peeks chat_id from pending_replies (no pop until success).
    • In-memory per-task attempt counter.
    • success → pop, archive, clear counter.
    • failure < cap (10) → leave files. Next iteration retries.
    • failure >= cap → log loudly, append [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_replies is 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 the send_reply bool contract:

  • happy path
  • ok: False, non-dict response, exception
  • chunking (all ok / middle fails)
  • file send (ok / fails)
  • text+file partial failure
  • blocked-file-path notice

Loop-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 + new tests/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

…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>
@amkunte
amkunte merged commit a1121c9 into main May 14, 2026
@amkunte
amkunte deleted the fix/telegram-bridge-send-reply-retry-cap branch May 14, 2026 04:19
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant