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

Dev client - #71

Draft
jgluck96 wants to merge 269 commits into
mainfrom
dev-client
Draft

jgluck96 wants to merge 269 commits into
mainfrom
dev-client

Conversation

@jgluck96

@jgluck96 jgluck96 commented Jun 1, 2026

Copy link
Copy Markdown

No description provided.

sonichi and others added 30 commits May 20, 2026 21:27
…t PR (sonichi#950)

startup.sh derived `committer.name`/`committer.email` from
stand-identity.json so `git log %cn` showed which fleet host (Mini,
MacBook…) crafted each commit. The feature comment assumed only the
*author* gates CLA-Assistant — but git 2.31+ honors `committer.*` config
natively, so the COMMITTER became `<host>@noreply.sutando.local`, a
non-GitHub identity with no CLA signature.

CLA-Assistant checks BOTH author and committer. Result: every commit
from a fleet host failed the CLA gate — PR sonichi#947 and others were blocked
purely on the committer field, even with a CLA-signed author.

Remove the attribution block. Also actively `--unset committer.*` on
every startup so hosts that already have the stale config in
`.git/config` self-heal on next boot (no manual cleanup needed).

Per-host attribution, if still wanted, belongs in a commit-message
trailer — CLA-Assistant ignores trailers; it does not ignore the
committer identity.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
closes sonichi#951) (sonichi#953)

SKILL.md step 0 told the loop to write a bare `core-status.json`. With
the session cwd at the repo root, the file lands in `<repo>/core-status.json`
while every reader — `health-check.py`, the web UI — resolves
`<workspace>/state/core-status.json` via `status_read_path`. Same
split-brain sonichi#947 fixed for the CLAUDE.md "Work Status" recipe; this is
the proactive-loop SKILL.md sibling Chi flagged in sonichi#951.

Step 0 now names the absolute path
`${SUTANDO_WORKSPACE:-$HOME/.sutando/workspace}/state/core-status.json`,
matching `workspace_default.py:status_path` and the CLAUDE.md fix.

Sibling check (per sonichi#951): grepped `core-status.json` across
`skills/*/SKILL.md` — only `cross-node-sync/SKILL.md` also names it, and
there it's a sync-exclusion list entry, not a writer. No other bare-path
writer to fix.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#906)

* docs: release process proposal (qingyun-sutando half)

Plan-only document. Owner asked for a release-process + migration-framework
proposal in #dev. Sutando-Mini and I split it: Mini covers when-to-cut + tag
conventions; this doc covers what-goes-in + CHANGELOG culture + migration
framework + joint engine↔product coupling section.

270 lines covering:
1. CHANGELOG culture — 5 categories, author-curated + release-curator-pruned.
2. Migration framework — numbered migrations/NNNN-*.{sh,py},
   state/schema-version.json, idempotent startup-runner, 1-release
   backward-compat overlap (formalizes the ad-hoc sonichi#876 alias pattern),
   per-migration tempdir tests, 3-phase rollout.
3. Engine↔product coupling — Option A loose coupling (recommended) vs
   Option B lockstep. Engine = sonichi/sutando, Product = Sutando.app.
4. 4 open questions for owner.

Located under docs/ (parallel to docs/workspace-design.md's RFC pattern),
not notes/ (gitignored). Promotion to docs/release-process.md waits on
owner + Chi greenlight and Mini's companion proposal.

No code shipped. Plan only.

* docs(rfc sonichi#906): add Overview + Motivations sections per owner ask

Owner asked in two consecutive Discord messages (task-1779223601794 +
task-1779223627953):
- "Update the RFC to include motivations"
- "Give an overview picture of what we are trying to do, why it matters"

Two new sections at the top of the proposal:

1. **Overview** — TL;DR framing. What (release process + migration
   framework), why it matters (rollback discipline, commercial pinning,
   silent-breakage prevention, bot coordination, continuous gate), what
   it does NOT try to do (time-based cadence, backward migrations, data
   re-encoding, engine-product lockstep), and the co-author split.

2. **Motivations** — 5 numbered motivations (M1-M5):
   - M1: named snapshots of known-good states
   - M2: state-format changes happen often, silently breaking users
   - M3: commercial deploys need a pin point
   - M4: two bots are coordinating, need a shared spec
   - M5: release process IS the migration-test framework

Plus an explicit **Non-goals** subsection listing what we're deliberately
NOT solving here (time cadence, backward migrations, etc.) — anchors
scope for future contributors.

No content change to Parts 1-2 or the joint section; just preamble that
explains why before the design pages dive in.

* docs(rfc): add Mini's half — when to cut + tag conventions

Half of the consolidated release-process RFC (sonichi#906). Covers:
- When to cut a release (feature-completion trigger, owner cuts the tag)
- SemVer vs CalVer (SemVer; engine-vX.Y.Z prefix)
- Tag on main vs release branch (tag on main; no release branch)
- Git tag vs GitHub Release (both — tag is anchor, Release is narrative)
- What to cut next (aligned with qingyun-sutando: cut v0.1.0 now,
  framework as v0.1.1)
- Explicit coupling table mapping every joint surface to the
  corresponding section in qingyun-sutando's half

Plan-only. No code shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rfc sonichi#906): remove commercial framing — OSS readers may not be from AG2

Owner asked (task-1779223950387): "Remove the part about 'Commercial
pinning'. Remember OSS user may not care about commercial things as
they are not necessarily from Ag2 the company."

Changes:
- Overview "Commercial pinning" bullet → "Pinning for anyone building
  on top" (forks, downstream consumers, sister-node fleets — generic
  framing that includes but doesn't assume AG2-commercial users).
- M1 dropped "(commercial waitlist, OSS contributors, sister-node
  fleets)" parenthetical — just "users."
- Removed entire M3 "Commercial deploys need a pin point" motivation
  (was specifically about sutando.ag2.ai install instructions). M4/M5
  renumbered to M3/M4.
- Joint section's bottom bullet reworded: "Customer who hits a bug
  correlates ..." → "Anyone running the bundle who hits a bug ...
  Downstream consumers can pin install instructions to an engine tag."

Per memory feedback_internal_vs_public_docs.md — the sonichi/sutando
repo is PUBLIC; specific paths and commercial framings belong in
INTERNAL (#dev, workspace, owner DM), not in landed docs.

* docs(rfc sonichi#906): owner-quote line removed; M3 restored as downstream-pin-point (AG2-internal facing too)

Owner two follow-ups:

1. task-1779224091789: "Remove wordings like this — Owner (Chi)
   explicitly said 'Plan and make proposals first.'" — the preamble
   quote framing owner's instruction directly was leaking internal
   chatter into a public-repo doc. Replaced with neutral "This is half
   of a co-drafted proposal" framing.

2. task-1779224131237: "M3. Commercial deploys need a pin point —
   This is AG2 internal facing as well." — clarified that the doc
   serves both OSS contributors AND AG2-internal-facing audiences
   (sutando.ag2.ai, packaged bundles, commercial integrators). Restored
   as M3, generalized to "Pin points for downstream consumers and
   bundle deploys" — explicitly enumerates forks, sister-node fleets,
   Sutando.app, AG2-internal deploys, commercial integrators. M4/M5
   renumber accordingly (Two bots coordinating + release-process-is-
   migration-test).

Companion file `docs/release-process-proposal-mini.md` landed in
7d4f260 (Mini's half: when to cut + tag conventions). Both halves now
live on this PR's branch.

* docs(rfc sonichi#906): consolidated doc merging Mini's + qingyun-sutando's halves

Claimed the consolidation step in #dev bot-coord. Merges both source
files (both preserved on the branch as authorship record) into a single
review surface for Chi + Qingyun.

Structure:
- Top-of-doc overview + motivations + non-goals (combined from both).
- Part 1-4 from Mini (when to cut, SemVer + tag namespace, tag-on-main
  no release branch, git tag + GitHub Release + hard/soft gates).
- Part 5-6 from qingyun-sutando (CHANGELOG culture, migration framework
  with numbered migrations/state/runner/tests/phasing).
- Part 7 joint section (engine ↔ product coupling, Option A recommended).
- Part 8 "what to cut next" — both halves landed on (c): cut engine-v0.1.0
  now with manual migration notes, framework as v0.1.1.
- Coupling table verifying no double-coverage between the two halves.
- 5 consolidated open questions for owner (was 4 from each half; deduped
  + added Mini's signed-tags question).
- Next move: owner + Chi review, pick answers, then promote to
  docs/release-process.md + open implementation issues.

Source halves stay on the branch:
- docs/release-process-proposal-mini.md
- docs/release-process-proposal-qingyun-sutando.md

If owner greenlights, this consolidated doc moves to docs/release-process.md
and the two source files can be archived (or kept as authorship record).

No code shipped. Plan only.

* docs(rfc sonichi#906): integrate review-round resolutions

Integrates the 3 reviews on sonichi#906:
- Bassil (cold-review 2026-05-20 06:28) — 4-question picks aligned with
  Chi's later owner-pick; reinforces option (c) cut-now-with-manual-notes
  + framework as next-release primary payload.
- Lucy (review 11:31) — 3 implementation questions on the migration
  framework: multi-core race on schema-version.json, merge-order
  collision on migration numbers, health-check gate on fresh clone.
- Chi (sub-agent draft 19:28 + owner-pick 20:30) — final calls on
  3 open questions: bare v0.MINOR.PATCH (drop engine- prefix),
  working-state when-to-cut (not commit-count), unsigned tags for
  v0.1.x (signed deferred).

Concrete changes in the doc:
- Sweep `engine-vX.Y.Z` → bare `vX.Y.Z` everywhere (frontmatter
  status, body prose, schema-version.json example, alias timeline,
  coupling section, open-question text). 13 occurrences.
- Rewrite "Open questions for owner" as "RESOLVED 2026-05-20"
  section with each pick + reasoning (§§ 4a/4b for when-to-cut
  refinement + GPG deferral).
- §2.7 — replace cut-gate language with Chi's working-state
  framing + clarify health-check scope (tagging install, not
  fresh clone — addresses Lucy Q3).
- §2.8 (new) — Concurrency & ordering: resolve Lucy Q1
  (migrations run as pre-pool step in startup.sh, single-writer-
  by-construction; runner also called from resolve_workspace as
  idempotent fallback for manual sessions) and Q2 (number reserved
  at merge time, pre-merge CI check fails on collision, mirrors
  Rails/Alembic).
- Add "Future hardening" section gathering deferred items
  (signed tags, backward migrations, data re-encoding, PR-template
  migration checklist, docs/install.md fresh-install path,
  docs/release-process.md promotion).
- Rewrite "Next move" to reflect Chi's "mergeable" verdict + the
  Phase 1 framework PR sequence + cut v0.1.0 + workspace contract
  A/B as forcing function for migration 0002.
- Frontmatter: reviewers_integrated field naming the 3 reviews,
  status updated to "open questions resolved; pending merge",
  intro callout quoting Chi's mergeable line.

Plan-only doc — no code shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rfc): sweep consolidated doc to reflect Chi's 2026-05-20 owner-picks

The qingyun-sutando half integrated the review-round decisions, but the
consolidated doc — the file that promotes to docs/release-process.md —
was never swept and still contradicted them. Chi's stated merge bar was
"once the RFC text reflects these, it's mergeable"; this makes it so.

- engine-vX.Y.Z → bare vX.Y.Z everywhere (decision 1).
- §2.2 rewritten: bare tag namespace, no engine- prefix; the namespace
  collision the prefix defended against doesn't exist (product ships
  from its own repo / tag line).
- Part 1.1: add the working-state precondition (decision 4a) — CI green,
  no release-blocker, no half-finished migration; quality gate not count.
- "Consolidated open questions" → "Consolidated decisions (resolved)"
  with all 5 answers recorded inline.
- "Next move" + coupling table + frontmatter status updated to reflect
  that nothing is owner-gated anymore.

Proposal files (mini / qingyun) left as the authorship record per the
consolidated doc's own header; the mini half still reads `engine-` —
flag if it should be swept too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rfc): sweep mini proposal half to bare vX.Y.Z (consistency with sonichi#906 decisions)

Companion to e248cc6 (consolidated-doc sweep). The mini proposal half
still recommended the `engine-` tag prefix — the option the 2026-05-20
review round overrode. The qingyun-sutando half was already swept to
bare `vX.Y.Z`, so leaving the mini half unswept left the PR internally
inconsistent.

- engine-vX.Y.Z → bare vX.Y.Z throughout.
- §2.2 rewritten: records that this half's engine-prefix recommendation
  was overridden, states the resolved bare-vX.Y.Z decision + rationale.
- Coupling table + open-questions section marked resolved.

Per Chi's "up to you" call on whether to sweep the mini half. All three
release-process docs now consistent with the resolved decisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ons (sonichi#954)

The CLI session no longer runs the legacy /proactive-loop. CURATE + ACT
fire from cron entries; /schedule-crons just arms those crons + starts
the task watcher. Same end state, no loop bootstrap needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nitor (sonichi#959)

Per Lucy's sonichi#954 post-merge review: after sonichi#954 (bootstrap swap
/proactive-loop → /schedule-crons), the watcher no longer started at CLI
startup because schedule-crons step 4 only said "ensure one is running,
prompt the CLI to start it" — passive. The watcher came up only on the
first main-loop cron fire (~5 min later), leaving a startup gap where
voice / Discord / owner tasks queue unprocessed.

Step 4 now mirrors /proactive-loop activation step 2: actively invoke
the Monitor tool with `bash src/watch-tasks-stream.sh`,
`persistent: true`. Existing watcher (pgrep) → skip. Closes the gap.

Note on Lucy's question about crons.example.json missing curate-loop /
act-loop: those reference *personal* skills (~/.claude/skills/
personal-curate-loop, personal-act-loop) which live in private
memory-sync, not the public repo. Fresh public-install correctly
continues to arm `main-loop` → `/proactive-loop`. Not a fix needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…9 crash (sonichi#960)

`telegram-bridge.py:313` — `def send_reply(..., task_id: str | None = None)`
uses a PEP-604 union annotation. startup.sh launches the bridge via bare
`python3`, which is /usr/bin/python3 (3.9) on stock macOS. On 3.9 the
`str | None` annotation is evaluated at def-time and raises
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` —
crashing the bridge on import. Telegram has been silently down.

`from __future__ import annotations` (PEP 563) makes every annotation in
the file a lazy string, never evaluated at runtime — 3.9-safe. Same fix
class as the health-check py39-typing work.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `community-use-cases/` entry for the self-healing install use
case — a user's local Sutando clears a false macOS "damaged" flag on a
failed AG2 app install, kills the stale processes, and DMs the developer
the root cause, unattended. Links the @sutando-ai YouTube video
(PTO7dmlOhUg) + the X post. The showcase sync job stages it to
sutando.ai's "Use cases" section on merge.

Outcome-framed per community-use-cases/README.md (no "Sutando can…",
no feature naming). No thumbnail file — the entry carries youtubeId so
the showcase can use the video thumbnail; a still can be dropped at
community-use-cases/thumbnails/self-healing-install.jpg later if wanted.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi#889)

* feat(pointer-teacher): productionize the embodied pointer into Sutando

Brings Clicky's "flying pointer teacher" into the live app. The tracer
proved the loop end-to-end; this folds it in:

- point_at inline tool (src/browser-tools.ts): captures the main display
  via :7845, resolves the Target with gemini-3-flash-preview in Gemini's
  native point format with thinking disabled (ADR-0001 — 1-3px in the
  POC; flash-lite was 108px off, Clicky's raw-pixel prompt 23-69px off),
  writes <workspace>/state/pointer-cmd.json, returns the `say` line +
  an instruction so the voice agent narrates it.
- Registered in inlineTools + ownerOnlyTools (both required arrays).
- PointerOverlayView + setupPointerOverlay/flyPointer/holdPointer
  (src/Sutando/main.swift): screenSaver-level click-through window,
  Clicky bezier flight, beep + pulsing halo, ported verbatim from the
  tracer. Driven by the app's existing DispatchSource dir-watch idiom
  (same as watchResults) on <workspace>/state — resolves IPC open item.

Running inside Sutando.app's real menubar GUI session is the fix for
the "I didn't see any pointer" visibility problem the standalone tracer
binary hit (no GUI-session access when launched from the agent shell).

v1 ships voice-driven; the arming hotkey stays future scope (privacy
property already holds — capture happens only on invocation). AX-first
resolution (open item #3) remains the documented next layer.

Swift compiles with the app's build flags; full tsc --noEmit passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pointer-teacher): apply Codex review findings

Codex review of 9896c53 — verdict "ship with fixes". All 5 applied:

- HIGH (main.swift): flyPointer() now invalidates the hold/pulse/fade
  timers from a prior point_at and resets halo/alpha/showLabel, so a
  stale fade can no longer hide a new marker mid-flight. Timers are
  tracked (pointerPulseTimer/HoldTimer/FadeTimer); the fade closure
  takes [weak self].
- MED (browser-tools.ts): atomic IPC publish — write a sibling .tmp
  then renameSync over pointer-cmd.json so the dir-watcher never reads
  a half-written JSON.
- MED: AbortSignal timeouts + res.ok checks on both the :7845 capture
  and the Gemini fetch — point_at is on the inline lane and must not
  hang it.
- MED: per-invocation temp screenshot path + success flag so a failed
  sips can't feed a stale prior screenshot to the model; cleaned up.
- LOW: validate the parsed point is finite and in [0,1000] before
  publishing, instead of reporting success on garbage coords.

Swift compiles with the app flags; full tsc --noEmit passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pointer-teacher): match Clicky's real pointer technique + add diagnostics

User showed the marker was a fat 44px triangle inside a big pulsing
halo ring, centered ON the target so it covered what it points at —
nothing like Clicky's. Read Clicky's OverlayWindow.swift and matched it:

- Removed the stroked halo ring entirely. Clicky has none — just a soft
  blue glow shadow (radius 8, grows with flight scale).
- Triangle 44px → 18px (Clicky uses 16; +2 for retina legibility),
  Clicky's exact vertex ratios.
- Rest pose is now a -35° cursor tilt (PointerOverlayView.restAngle),
  not a straight-up triangle — reads as a pointer.
- pollPointerCmd offsets the target +8px right / 12px below the element
  (Clicky's "sit beside it, not on top") + 20px screen clamp, so the
  marker points AT the thing without covering it.
- Mid-flight scale swoop to ~1.3x (Clicky's animateBezierFlightArc),
  settles to scale 1 + cursor pose on arrival.
- Hairline white edge for legibility on dark/light UIs; smaller offset
  label so it never covers the target.

Also added logToFile diagnostics to pollPointerCmd/flyPointer (accepted
ts/coords, raw→target, START/ARRIVED, window visible+level) — this is
how the "not working" report was root-caused to a rendering issue, not
a broken pipeline.

Swift compiles with the app flags; restarted + log-verified end to end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pointer-teacher): apply Codex review round 2

Second Codex round on this branch (after 0cfb60c), all 4 findings:

- [High] browser-tools.ts: point_at temp file was per-process
  (`pointer-cmd.json.<pid>.tmp`), so two overlapping async calls
  clobbered each other's write/rename. Unique per-call temp name via
  a shared publishPointerCmd() helper (pid+ms+random); atomic rename +
  Swift ts-guard still picks the winner.
- [High] stale-overlay feedback loop: the :7845 server uses the
  `screencapture` CLI (raw framebuffer, ignores NSWindow.sharingType),
  so Codex's sharingType=.none would not have worked. Instead point_at
  publishes {hide:true} and waits ~250ms before capturing; main.swift
  learns the hide command (cancels timers, alpha 0, orderOut) and
  flyPointer re-orders the window front.
- [Medium] display reconfig: overlay was sized once at launch but
  pollPointerCmd maps against the current NSScreen.main.frame. Added a
  didChangeScreenParametersNotification observer + resizePointerOverlay().
- [Medium] POC tracer pointer-overlay.swift: hold() used local
  pulse/hold/fade timers that survived a new fly(). Mirrored the
  production stored-timer pattern so they're cancellable.

TS typecheck clean; both Swift files compile (-O).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(pointer-teacher): document POINTER_MODEL + verified deprecation status

gemini-3-flash-preview has no shutdown date on Google's deprecation
page (verified 2026-05-19); the May 25 gemini-3.1-flash-lite-preview
deprecation (sonichi#715) is a different model family. Add the POINTER_MODEL
env override to .env.example as the zero-code-change escape hatch and
record the verified status as design §5 open item 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…() (closes sonichi#934) (sonichi#971)

The TS side of the pointer-teacher IPC was resolving the state-file path via
process.cwd() instead of resolveWorkspace(). Works on the launch path (Sutando.app
starts services from the repo root, and the repo-root state/ happens to be the
same APFS inode as workspace/state/ on the dev machine), but silently bifurcates
the moment SUTANDO_WORKSPACE points anywhere else — Sutando.app would read
<workspace>/state/pointer-cmd.json while point_at writes <cwd>/state/pointer-cmd.json,
and the bezier pointer would never fly. No error, just silence.

The Swift side already routes through AppDelegate.workspace (which calls
resolveWorkspace()), so this brings the TS writer in line with the Swift reader.

Vidhu flagged this on sonichi#889 review; non-blocking and tracked as sonichi#934.
* fix(quota): readers use only the canonical workspace path

read-quota.py and dashboard.py both fell back to an in-repo
skills/quota-tracker/quota-state.json when the canonical
<workspace>/state/quota-state.json couldn't be resolved. A stale leftover
copy at that in-repo path (~12h old) silently shadowed the fresh file —
the dashboard's quota panel froze at 4% used with a past reset time while
the credential proxy was writing correct data all along.

Drop the in-repo (skill-dir / cwd) fallbacks. The credential proxy writes
exactly one file; the readers now read exactly that file, or report it
missing — no silent stale shadow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quota): session-handoff.sh reads canonical workspace quota path

session-handoff.sh read quota from $REPO/skills/quota-tracker/quota-state.json
(in-repo), fallback $REPO/quota-state.json — never the canonical
<workspace>/state/quota-state.json. Same split-brain as read-quota.py /
dashboard.py: a stale in-repo copy shadows the proxy-written file.

Found via the audit Chi requested on PR sonichi#970. Completes the fix class —
all three quota-state readers now resolve only the canonical path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chi#973)

Anyone who can speak in the voice channel previously inherited owner-tier
work/file-edit/message-send privileges by default. That's fine for a
single-operator Lounge, but it's a sharp edge to ship as the default —
inviting the bot to any channel with other speakers silently hands them
the proactive-loop surface.

Flip the default to false: non-owner speakers get the read-only tools
only. To restore the single-operator behavior, set DISCORD_VOICE_OWNER=true
explicitly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
… Desktop (sonichi#972)

Two safety fixes after a migrate.sh restore wiped real .env keys (2026-05-21):

1. Restore now backs up any existing .env to .env.bak.<timestamp> before
   overwriting. A stale or placeholder-key bundle .env can no longer
   silently destroy real credentials with no recovery path.

2. The bundle moves from ~/Desktop/sutando-migration to
   ~/.sutando/migration-bundle. The bundle carries .env with real secrets;
   ~/Desktop is iCloud-synced on many Macs, which would push unscrubbed
   credentials to the cloud. ~/.sutando/ is not sync-backed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
… writers (sonichi#974)

* chore(discord-voice): record turns to conversation.sqlite, drop JSONL writers

sonichi#791 migrated voice + phone session logging to conversation.sqlite but left
discord-voice still writing per-session JSONL (discord-voice-*.jsonl) plus
discord-voice-metrics.jsonl. This finishes that migration — the piece sonichi#791
left undone.

discord-voice-server.ts now calls recordConversation / recordSession from
src/conversation-store.ts (which already declares 'discord-voice' as a
session source); the two JSONL writers and the now-unused logLine/LOG_PATH
helper are removed.

Closes sonichi#792.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(discord-voice): mirror turns to conversation.log; preserve channel id

Addresses review on sonichi#974:
- Mirror discord-voice turns into logs/conversation.log alongside the
  conversation.sqlite write, matching the phone path. Without the text-log
  copy, `import-conversation-log.py --reload` (which rebuilds the sqlite
  `conversation` table from conversation.log) would silently drop
  discord-voice rows.
- Encode guild + channel id into the session id so channel-level
  diagnostics survive into the sessions table (recordSession has no
  guild/channel field).
- SKILL.md: correct the stale "metrics land in JSONL" line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(discord-voice): guard --reload against the discord-voice writer

Addresses re-review on sonichi#974:
- Add discord-voice-server.ts to _LIVE_WRITER_PATTERNS in both
  import-conversation-log.py and import-session-metrics.py. discord-voice
  is now a live conversation.sqlite writer, so the --reload live-writer
  guard must detect it or a reload could race (and drop) its rows.
- Write conversation.log before the sqlite mirror so a turn never exists
  in sqlite without its conversation.log line — matches the primary-log
  contract the reload importer depends on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nichi#977)

A Gemini Live session can silently stall — audio keeps flowing in but it
stops emitting turn.end, with no transport-close event to trigger the
existing reconnect path (observed 2026-05-21: a session dead ~6min until
a manual restart).

Add a 10s-interval watchdog: if >=2 utterances have finished since the
last turn activity and the user last stopped speaking more than
WATCHDOG_STALL_MS (default 20s, env-overridable) ago, force a reconnect
through the same handleClientConnected() path a transport close uses.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
save_note resolved NOTES_DIR against the workspace (correct since sonichi#842),
but never ensured the notes/ subdir exists. On a fresh install — or any
workspace without a prior note — the first save_note failed with ENOENT
and surfaced to the user as a generic "issue saving the file".

Add mkdirSync(NOTES_DIR, { recursive: true }) before writeFileSync so the
first save always succeeds.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ichi#981)

## The bug

`_is_safe_callback_url` in `src/agent-api.py` is the SSRF defense for
webhook callbacks. It loops `getaddrinfo` results against a list of
private-IP networks. For IPv4-mapped IPv6 addresses (`::ffff:127.0.0.1`
and similar), Python's cross-family `in` check is always False:

    >>> import ipaddress
    >>> ipaddress.ip_address('::ffff:127.0.0.1') in ipaddress.ip_network('127.0.0.0/8')
    False  # cross-family

So a webhook URL of `https://[::ffff:127.0.0.1]/exfil` passes the
validator even though it resolves to localhost. Same gap for any
RFC1918 target (`::ffff:10.0.0.5` -> 10.0.0.5, etc.).

Practical exploit is **TLS-gated** — no public cert covers IPv6 IP
literals, so `urllib.request.urlopen` on this URL fails certificate
validation. But defense-in-depth says the validator MUST catch it —
the function's docstring explicitly claims to block "Hostnames that
resolve to private IPs" and the SSRF allowlist is the canonical guard
for callback URLs.

## Fix

After the existing per-address loop, if the address is an
`IPv6Address` with `ipv4_mapped` set, project the mapped IPv4 onto
the IPv4 private-range checks. Catches the bypass with no change to
the existing IPv4 / pure-IPv6 paths.

Note on 6to4 / Teredo: 6to4 (`2002::/16`) and Teredo (`2001::/32`)
are public-routable IPv6 tunneling schemes whose embedded IPv4 is
the *encapsulating endpoint*, not the destination — projecting
those onto private-IPv4 checks would generate false positives. Only
v4-mapped (`::ffff:0:0/96`) needs the projection.

## Tests

`tests/agent-api-ssrf-ipv4-mapped-ipv6.test.py` (6 cases). Monkey-
patches `socket.getaddrinfo` so each case deterministically simulates
a DNS response — no network dependency.

  - `test_ipv4_mapped_ipv6_loopback_is_blocked` — the core bypass
  - `test_ipv4_mapped_ipv6_rfc1918_is_blocked` — RFC1918 generalization
  - `test_plain_ipv4_loopback_still_blocked` — no regression on plain v4
  - `test_plain_ipv6_loopback_still_blocked` — no regression on plain v6
  - `test_public_ipv6_still_passes` — defends against over-zealous fix
  - `test_public_ipv4_still_passes` — sanity

All 6 pass locally.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
…pt injection (sonichi#994)

## The bug (security)

`pressKeyTool` in `src/inline-tools.ts` accepted an optional `app`
parameter and embedded it raw in an AppleScript command:

  if (app) {
    execSync(`osascript -e 'tell application "${app}" to activate'`, ...);
  }

A value containing `"` breaks out of the AppleScript string literal
and injects arbitrary AppleScript. AppleScript supports
`do shell script "..."`, so this is arbitrary code execution from a
tool-call argument.

PoC (pre-fix):

  pressKey({
    key: 'space',
    app: '"); do shell script "rm -rf ~/sensitive"; tell application "Finder',
  })

Resulting AppleScript:

  tell application """); do shell script "rm -rf ~/sensitive"; tell application "Finder" to activate

…runs the shell command.

## Why this is the right fix

Two other call sites in the same file already use the correct escape
pattern (`safeKey` at line ~161, `safeApp` in switchAppTool around
line ~214). Only `pressKeyTool`'s app-activation branch was missing
the escape. Apply the same 3-step strip (backslash, single-quote,
double-quote). No new abstraction needed — local fix where the bug
lives, matches the established in-file pattern.

## Tests

`tests/inline-tools-press-key-app-escape.test.ts` — 4 source-grep
architectural assertions:

  - raw `tell application "${app}"` pattern absent (pre-fix pattern
    fails here)
  - pressKey branch references `safeApp`
  - `safeApp` computed via `.replace()` chain — pins all 3 strips
  - Chain includes the `"` strip — the actual exploit-vector defense

Source-grep is the right shape: actual `execSync` call would spawn
osascript and is OS-dependent; the security contract is "escape `app`
before embedding," which grep verifies precisely.

All 4 pass locally. `npm run typecheck` clean.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
…ichi#978)

* chore(diagnose-call): read call metrics from conversation.sqlite

sonichi#603 migrated call/voice metrics from JSONL to conversation.sqlite and
diagnose.py was updated to match, but
skills/regression-search/scripts/diagnose-call.py was left reading the
now-frozen data/call-metrics.jsonl + results/calls/calls.jsonl. Point it
at the sqlite `sessions` + `conversation` tables instead — finishing the
sonichi#603 migration for this script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(diagnose-call): resolve conversation.sqlite under $SUTANDO_WORKSPACE

Review fix on sonichi#978. The DB path resolved to repo-/cwd-relative
`data/conversation.sqlite`, but conversation.sqlite is per-user runtime
state living under the resolved workspace — repo-relative resolution
makes `diagnose-call <sid>` always print "No call matches". Use
resolve_workspace() + honor SUTANDO_CONVERSATION_DB, matching diagnose.py
and the import-* scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ndo-skills (sonichi#995)

The main-repo `skills/info-radar/` was a SKILL.md-only stub: no scripts, no
manifest, and no cron driving it. The functional info-radar — adaptive-x-feed
recommender + x-radar-daily + append-signal + tests — lives in the private
`sonichi/sutando-skills` repo (added #14, fixed #15), whose SKILL.md is a
strict superset of this stub (keeps the static topic-monitor as a Legacy
section, adds the adaptive recommender).

Keeping both created a dual-home: `install.sh` skipped re-linking info-radar
("symlink points elsewhere"), pinning `~/.claude/skills/info-radar` to this
non-functional stub while the real version stayed dormant. Removing the stub
lets the next skills-sync install re-link to the functional copy.

info-radar is intentionally staged private (sutando-skills) while its scripts
are not yet owner-validated; it returns to the public repo once tested.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## The bug

`src/dashboard.py` binds to `0.0.0.0` with no authentication. The
server exposes the owner's full notes corpus, recent activity,
system stats, and identity:

  - GET /json         — owner score, health, activity log, pending
  - GET /notes        — list of every owner note (titles + slugs)
  - GET /notes/<slug> — FULL TEXT of any owner note
  - GET /avatar, /stand-identity

Any device on the LAN can read all of the above. Notes routinely
contain personal info, work-in-progress thoughts, contact info,
sometimes secrets.

Concrete repro: from any device on the same network,
`curl http://<owner-host>:7843/notes` lists every note slug, then
`curl http://<owner-host>:7843/notes/<any-slug>` dumps content.

## Fix

Default the bind to `127.0.0.1`. Add `DASHBOARD_BIND` env var so
users who legitimately want LAN exposure can opt in (same shape as
`AGENT_API_BIND` in `src/agent-api.py`).

LAN-bind warning surfaces the no-auth posture when opted into:

    Sutando Dashboard → http://0.0.0.0:7843
      (LAN access enabled via DASHBOARD_BIND=0.0.0.0 — the dashboard
       has NO authentication; anyone on this network can read your
       notes, activity, and identity)

## Why this is the right fix

  - Default-secure. Most users want loopback-only.
  - Escape hatch via DASHBOARD_BIND for the rare user with a
    multi-device need.
  - Loud warning so nobody re-introduces the LAN-readable-notes
    class silently.
  - Minimal change.

Alternative: add per-endpoint auth. Rejected — the dashboard is
read-only and meant for the owner's own browser; a default-localhost
bind is the standard pattern (Jupyter Notebook ships
`--ip=127.0.0.1` by default for the same reason).

## Tests

`tests/dashboard-default-loopback.test.py` (3 cases) — source-grep:

  - 0.0.0.0 is not the literal default in the HTTPServer constructor
  - DASHBOARD_BIND is read with a 127.0.0.1 default
  - LAN-warning + "NO authentication" string present in the conditional

All 3 pass locally.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…onichi#985)

Three real bugs in the REST-only fallback delivery path used by
`poll_dm_fallback` when voice / the live bridge can't reach the user:

## Bug A: `_resolve_owner_id` ignored `tierMap`

`src/discord-bridge.py:poll_proactive` honors the documented priority
chain `$SUTANDO_DM_OWNER_ID` -> `tierMap[uid] == "owner"` -> bot-filter
fallback. `dm-result.py` only honored env override + bot-filter — drift
between the live bridge and the fallback path. An admin who tier-tagged
the owner in access.json saw the live bridge route correctly, but the
fallback could pick a different non-bot from allowFrom (or a bot, in
the degraded-network case where /users/{id} lookups all fail).

Fix: insert the tierMap branch between env override and bot-filter.
Defensive: only accept tier-tagged owner if still present in allowFrom
— stale tags from removed users must not resolve a delisted owner.

## Bug D: `send_dm` delivered `[file:|send:|attach:]` markers as
## literal text

`poll_dm_fallback` shells out to `dm-result.py --file results/task-X.txt`
to deliver result files when the live bridge can't. Result files often
embed file markers:

    Here's the screenshot you asked about: [file: /tmp/sutando-x.png]

The live bridge attaches the file via `discord.File(path)` on the
WS-connected client. dm-result is REST-only and has no multipart
upload path — so the marker landed verbatim in the DM and the file
was silently dropped.

Fix: mirror discord-bridge's `_split_file_markers` helper.  `send_dm`
now strips markers from the body before chunking and logs the dropped
file list. Actual REST multipart upload is a follow-up — flagged in
the inline comment.

## Bug C (secondary): empty-body-after-strip posted `""` -> Discord 400

Pre-fix: `chunks = list(_chunk_for_discord(text)) or [text]`. For a
whitespace-only or all-marker body, the chunker yields nothing ->
empty list -> falsy -> falls back to `[""]` and POSTs empty content.
Discord returns HTTP 400 / code 50006.

Fix: detect empty-after-strip and skip the /messages call entirely.

## Tests

`tests/dm-result-send-dm.test.py` (5 cases). Mocks
`urllib.request.urlopen` with a recording fake (`_FakeTransport`) and
probes the full REST flow. The fake raises `AssertionError` on any
unmocked request, so future regressions fail loudly.

  - `test_tier_map_resolution_skips_bot_lookup` (Bug A regression
    guard + perf assertion that the wasted /users/{id} call is gone)
  - `test_bot_filter_fallback_still_works_without_tier_map`
    (existing behavior preserved)
  - `test_file_markers_stripped_from_body` (Bug D regression guard)
  - `test_empty_body_after_marker_strip_does_not_post_messages`
    (Bug C regression guard — the fake transport raises
    AssertionError on an unmocked POST, so a future regression here
    fails loudly)
  - `test_env_override_skips_access_json_entirely` (priority-order
    pin)

All 5 pass locally.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…sonichi#986)

A relative `SUTANDO_WORKSPACE` resolved against CWD-at-use-time
rather than against any single deterministic anchor. Two processes
inheriting the same env value but running with different CWDs would
mkdir/read in DIFFERENT directories — silent split-brain.

The module's own docstring already warns about the same anti-pattern
for the `Path(__file__).resolve().parent.parent` fallback. The
env-path branch had no equivalent guard.

Fix: detect relative paths in the env-set branch and anchor against
the current CWD via `(Path.cwd() / target).resolve()`. Emit a single
stderr warning so misconfiguration surfaces instead of going silent.

`.resolve()` collapses `..` segments too, matching the path-injection
sanitizer pattern used in `_is_path_sendable`.

Tests: `tests/workspace-default-relative-env.test.py` (3 cases):

  - relative env path is anchored absolute (bug regression guard)
  - absolute env path passes through unchanged (backwards compat)
  - tilde expansion preserved

All 3 pass locally.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…rs (sonichi#987)

## The bug

An agent's reply body sometimes contains an inline-code description
of the file-marker convention itself, e.g.:

    Body example like `[file: /tmp/sutando-x.png]` reached the user…

The bridge's `[file:|send:|attach:]` regex is markdown-context-blind:
it extracts every matching substring regardless of whether it sits
inside backticks, a fenced code block, a blockquote, or plain prose.
For prose-quoted markers, the path doesn't exist, and the
`(file not found: ...)` Discord/Telegram message lands in the user's
DM as confusing noise AFTER the agent's clean reply.

## Fix: minimum user-impact

Two layers were considered:

1. Make extraction markdown-aware. Principled but invasive (regex ->
   real parser; reaches every send path).
2. Silence the user-facing warning for the missing-file case. Real
   typos still surface to operators via stderr; only the user-
   visible Discord/Telegram noise goes away.

This PR ships #2. Replaces four `(file not found:)` send sites with
stderr log lines:

  - src/discord-bridge.py: poll_results, poll_proactive,
    poll_dm_fallback channel-redirect
  - src/telegram-bridge.py: send_reply

Each carries a comment pointing back to this bug class so the next
refactor doesn't restore the warning by accident.

## What's NOT silenced

`(file not allowed: ...)` / `(file access denied: ...)` — the
allowlist rejection branch. That's a security signal (attempted
exfil of a file outside SEND_ALLOWED_ROOTS / SEND_ALLOWED_PREFIXES)
worth surfacing to both user and operator. Test pins this contract.

## Tests

`tests/discord-bridge-prose-marker.test.py` (3 cases) — source-grep
regression guards:

  - no `.send(f"(file not found: ...)` patterns remain in discord-bridge
  - telegram-bridge specifically pinned alongside Discord (bridges
    historically drift on shared patterns)
  - `(file not allowed: ...)` still surfaces (security signal)

Source-grep is the right shape — invoking the async poll_* methods
end-to-end would need a discord.py harness, and the fix is a local
behavioral change in well-bounded branches that grep verifies
precisely.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…i#988)

## Why

`results/proactive-*.txt` files (the file-bus for unsolicited owner-
notifications) were polled by EVERY configured bridge —
`discord-bridge.py:poll_proactive` AND `telegram-bridge.py`'s proactive
loop. The atomic-rename claim correctly enforced "deliver at most
once" but was a RACE — whichever bridge's loop reached the file first
delivered. With both Discord and Telegram configured, proactive
messages randomly fanned out to one channel or the other based on
poll timing.

The race-claim was correct as a concurrency primitive ("don't
double-deliver") but wrong as a routing primitive ("don't deliver
where the owner isn't reading"). A Discord-context follow-up could
land on Telegram and vice versa.

## Fix

Both bridges already record owner activity via
`write_owner_activity(channel, summary)` ->
`state/last-owner-activity.json`. Use that to route.

New `src/proactive_routing.py` exports one pure function:

  should_claim_proactive(state_file_path, this_channel) -> bool

Decision rule:
  - state file says `data["channel"] == this_channel` -> True (claim)
  - state file present, channel different -> False (skip; the other
    bridge should claim)
  - state file missing / malformed / no channel / non-dict root ->
    default `this_channel == "discord"` (predictable fresh-install
    routing)

Both bridges' proactive loops now call this BEFORE the
`for f in RESULTS_DIR.iterdir()` claim loop. If the channel doesn't
match, the bridge sleeps and skips entirely.

## Tests

`tests/proactive-routing.test.py` (8 cases) covers every branch:

  - discord-active routes to discord
  - telegram-active routes to telegram (symmetric)
  - missing state file defaults to discord (race-leak closed)
  - malformed state file defaults to discord (fail closed)
  - missing channel field defaults to discord
  - empty channel string defaults to discord
  - non-dict root defaults to discord (type guard)
  - unknown channel value does not match anyone (strict equality,
    no Discord-fallback for known-other channel)

All 8 pass locally. The race-leak is now closed, and the rule is
strictly symmetric — Telegram users get the same correctness.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…onichi#989)

## Why

Three independent .env loaders (`src/telegram-bridge.py`,
`skills/x-twitter/x-post.py`, `skills/image-generation/scripts/generate.py`)
parse .env lines with the same pattern:

    for line in env_path.read_text().splitlines():
        if "=" in line and not line.startswith("#"):
            key, _, val = line.partition("=")
            os.environ[key.strip()] = val.strip()

This handles unquoted lines correctly (`KEY=value`). It does NOT
handle quoted lines: `KEY="value"` -> `os.environ["KEY"]` becomes
`'"value"'` (with literal quotes). The quoted form is a common .env
convention.

Each loader's downstream consumer then uses the quoted string
verbatim:

  - x-post.py: OAuth1 signature base string includes the quotes ->
    X returns 401.
  - telegram-bridge.py: URL becomes
    `https://api.telegram.org/bot"abc"/getUpdates` -> Telegram
    returns 404.
  - generate.py: Gemini Authorization header gets a malformed key.

## Fix

Mirror python-dotenv's behavior: strip ONLY matching surrounding
quotes. A 3-line check after the existing `val.strip()`:

    if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
        val = val[1:-1]

Handles every common .env shape:

  - KEY="value"   -> value
  - KEY='value'   -> value
  - KEY=value     -> value (unchanged)
  - KEY="abc      -> "abc  (mismatched — left intact)
  - KEY="a"b"     -> a"b   (outer pair stripped; inner preserved)

Alternative considered: extract to a shared `src/env_loader.py`.
Rejected for now because the call sites span `src/` (core) and
`skills/` (which by design avoid importing from `src/` for
self-containment). A shared module would require sys.path tweaks in
each skill that adopts it, undoing the skill-isolation contract.

## Tests

`tests/env-loader.test.py` — extended with the quote-strip invariant.
Greps each loader for the matching-quote-strip pattern (`val[0] ==
val[-1]` or `v[0] == v[-1]`). All 3 loaders pass against the new
check.

Pre-fix: structural grep of any of the 3 files would NOT match the
pattern -> test fails.
Post-fix: all 3 contain the pattern -> test passes.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…ach:] regex (sonichi#990)

The `[file:|send:|attach:]` marker regex was duplicated inline at THREE
sites:

  - poll_results (channel replies)
  - poll_proactive (owner DMs)
  - poll_dm_fallback channel-redirect

Any future hardening to the pattern had to land in all three places
or one path would drift. The pre-existing pattern required absolute
paths (`/...` or `~/...`) because relative paths resolved against
the bridge's CWD, which differed between launchd-managed and bare-
shell runs.

## Changes

- `_FILE_MARKER_RE` — module-level compiled regex.
- `_split_file_markers(text) -> (clean_text, files)` — pure helper.
  Three call sites now call it instead of re-defining the regex.

No behavior change. Refactor is byte-for-byte equivalent for valid
inputs; the only difference is that the regex compiles once at
module-load instead of per polling-loop iteration (minor perf win).

## Tests

`tests/discord-bridge-file-markers.test.py` (10 cases):

  - common case (no markers), empty input, single-marker extraction
  - `file` / `send` / `attach` keyword aliases
  - absolute paths only — relative paths rejected
  - multiple markers preserve textual order
  - `[reply: <id>]` directive does NOT match the file pattern
  - unknown keywords (`[path:]`, `[url:]`, etc.) don't silently extract
  - ARCHITECTURAL: the regex body appears exactly once in the source
    — guards against a future refactor accidentally re-introducing
    an inline copy at a fourth call site

All 10 pass locally.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…ck only (sonichi#992)

## The bug (security)

`skills/phone-conversation/scripts/conversation-server.ts` binds to
`0.0.0.0` to accommodate ngrok's local-tunnel client + the Twilio
webhook path. That makes EVERY endpoint LAN-reachable. /twilio/*
paths validate Twilio's signature; the rest are unauthenticated
control endpoints:

  - POST /call             - originate an outbound Twilio call
  - POST /concurrent-call  - originate child call during a parent
  - POST /hangup           - terminate an active call
  - POST /play-audio       - stream audio through Twilio
  - POST /stop-audio       - stop active playback
  - POST /meeting          - start meeting-mode session
  - POST /meeting-approve  - upgrade meeting to task-delegation

Concrete attack: anyone on the LAN POSTs:

  curl -X POST http://<host>:3100/call \
       -d '{"to":"+1xxx","message":"go"}'

Triggers an outbound Twilio call from the owner's account. This:
  - costs money (Twilio per-call charges),
  - reveals the owner's Twilio caller-ID,
  - can be abused for harassment / robocall through the owner's
    account,
  - combined with /concurrent-call, allows spam-dialing multiple
    targets in parallel.

`/hangup` and `/stop-audio` let any LAN caller disrupt active owner
calls. `/play-audio` lets them stream arbitrary `/tmp/sutando-*`
audio during the owner's calls.

## Fix

New module `loopback_guard.ts` exports `isLoopback(req)`, a one-line
predicate against `req.socket.remoteAddress` covering IPv4 loopback
(`127.0.0.1`), IPv6 loopback (`::1`), and the IPv4-mapped IPv6 form
(`::ffff:127.0.0.1`) that some Node builds report on dual-stack
sockets.

In the request handler, BEFORE the endpoint dispatch:

  if (path !== '/health'
      && !path.startsWith('/twilio/')
      && !isLoopback(req)) {
    json(res, 403, { error: 'control endpoints are loopback-only' });
    return;
  }

  - /twilio/* exempt: validated downstream by TWILIO_AUTH_TOKEN HMAC
    signature check.
  - /health exempt: read-only liveness probe useful for LAN-side
    monitoring.
  - Everything else is loopback-only.

Logs the rejected remote address to stderr for operator triage.

## Why this is the right fix

  - Minimal blast radius: one predicate, one gate, before the
    dispatch table. Easy to audit.
  - Matches existing in-codebase pattern (`_is_path_sendable`
    realpath-collapse for files; this is the network-layer
    equivalent).
  - Defends against the IPv4-mapped IPv6 bypass class (same shape
    as `_is_safe_callback_url` SSRF fix).
  - Doesn't change the bind address — ngrok still works, Twilio
    webhooks still reach /twilio/* over the public tunnel, /health
    probes still work LAN-side.

Alternative considered: switch bind from 0.0.0.0 to 127.0.0.1 and
route ngrok separately. Rejected because it changes the deployment
shape and would break installs where ngrok isn't the tunneling
solution. The endpoint-level gate is cleaner.

## Tests

`tests/phone-conversation-loopback-guard.test.ts` — 14 cases:

10 direct unit tests of `isLoopback` against various
`remoteAddress` values (loopback v4/v6/mapped-v6 accepted; LAN v4,
public v4, link-local v6, ULA v6, IPv4-mapped IPv6 to NON-loopback,
empty/undefined all rejected).

4 source-grep architectural assertions on conversation-server.ts:
  - imports isLoopback from the guard module
  - gates non-/twilio non-/health endpoints with the predicate
  - returns 403 (not 404 or silent) on the rejection path
  - logs the rejected remote address for incident triage

All 14 pass locally. `npm run typecheck` clean.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…on (sonichi#983)

## The bug

`main()` resolved the proactive-DM recipient via
`next(iter(load_allowed()))`. `load_allowed()` returns a `set`, so
iteration order was hash-slot order, not list order. With multiple
users in `allowFrom` (e.g. admin adds a second sender via
`/telegram:access allow`), proactive owner-notifications could route
to the wrong human.

Same bug class hit Discord's `poll_proactive` earlier — the canonical
fix there extracts the priority chain to a pure function. Telegram
needs the same shape.

## Fix

Extract `_resolve_proactive_owner_id`, a pure function with a
documented priority chain:

  1. $SUTANDO_DM_OWNER_ID env override
  2. tierMap[uid] == "owner" — explicit admin tier signal
  3. tofuOwner — first-install record. Only honored if still in
     allowFrom (admin opt-out via removal)
  4. First entry in allowFrom IN LIST ORDER

`main()` reads ACCESS_FILE + os.environ and hands the values in. The
resolver is pure (no I/O), unit-testable.

Also fixes a secondary issue: pre-fix `if owner_ids:` guarded
truthiness of the set; the new `if owner_id is None:` is explicit,
and the empty-allowFrom path now logs
`[proactive] no owner in allowFrom, skipping ...` instead of silently
doing nothing.

## Tests

`tests/telegram-bridge-proactive-owner-resolution.test.py` (12 cases)
covering every branch of the chain plus three regression guards:

  - env override beats everything (Priority 1)
  - tier-map owner wins over list-order (Priority 2)
  - tofu-owner used when tier-map absent (Priority 3)
  - tofu-owner ignored when removed from allowFrom (admin opt-out)
  - tier-map beats tofu-owner
  - first-in-list as fallback (Priority 4 — the bug fix)
  - list-order is authoritative under permutation (regression
    guard for set-conversion)
  - empty allowFrom returns None — caller logs + skips, not crashes
    on `int(None)`
  - missing tierMap doesn't raise (backward compat with older
    access.json)
  - tier-tagged owner must appear in allowFrom (defensive)

All 12 pass locally.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…hi#1002)

* fix(discord-bridge): auto-thread replies to the triggering message

When an agent writes a result without an explicit [reply: <id>] marker, the
bridge previously sent it as a fresh channel message — losing the visual
anchor between the question and answer. With multiple owner messages
arriving close together, agent replies became orphaned in the channel
timeline and Chi had to scroll to figure out which reply went with which
question (observed 2026-05-22 02:30: "what are you answering? can you
reply to the right msg?").

Fix:
- task file now carries `source_message_id: {message.id}` (one new line)
- in-memory `pending_reply_anchors[task_id]` tracks it alongside `pending_replies`
- result-send path defaults `reply_to_id` to the anchor when no explicit
  `[reply: <id>]` marker was provided
- skipped when the channel is a `discord.Thread` — in-thread messages
  inherit thread context, no quote-reply needed (per Chi 2026-05-22
  02:39: "if the msg is in a thread already then no need to reply_to")

Explicit `[reply: <other_id>]` still wins — agents can intentionally
respond to a non-triggering message (e.g. answer Q from msg A in the
context of msg B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): tolerate test-stub discord module without Thread

CI on PR sonichi#1002 caught: tests/discord-bridge-reply-directive.test.py stubs
a minimal `discord` module that intentionally omits `Thread` (kept small
on purpose). The new `isinstance(channel, discord.Thread)` check raised
AttributeError under that stub, breaking case b-no-directive-no-reference.

Fix: getattr(discord, 'Thread', None) so the missing attribute is treated
as "not a thread" — falls through to the auto-anchor branch. Production
discord.py always provides Thread; this only changes test behavior.

Verified locally with `python3 tests/discord-bridge-reply-directive.test.py`
— all 3 cases pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tream (sonichi#1003)

Live-test caught: PR sonichi#1002's auto-thread never fired because the result-send
loop popped pending_reply_anchors[task_id] immediately after pending_replies
pop, then ~50 lines later tried to .get(task_id) — which returned None
because the pop already removed it.

Capture the anchor into `source_message_anchor` at pop time; reference that
local in the auto-thread conditional. Unit test still green (covers the
"explicit [reply: <id>] directive present" + "no directive, no reference"
cases — the latter passes because pending_reply_anchors is also empty in
the test fixture, which is why the unit test missed the bug).

Symptom: reply went as a fresh channel message instead of a quote-reply
threaded to the triggering msg. Caught 2026-05-22 ~03:00 UTC by Chi
("it's not a quote reply") on the first live verification round of sonichi#1002.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi and others added 6 commits June 3, 2026 08:10
…chi#1400)

* fix(restart-voice-agent): clear stale pid file before kickstart

The voice-agent writes .voice-agent.pid on startup and exits if the file
already exists. When launchctl kills the LaunchAgent wrapper but leaves the
node worker alive (orphaned — observed 2026-06-02 after kickstart -k), the
new instance finds the stale pid file and immediately exits. The LaunchAgent
sees a clean exit (not a crash) and doesn't respawn, so kickstart appears to
succeed from the caller's view while audio stays broken.

Fix: read and kill the stale pid before kickstart, then remove the file so the
new instance can write its own pid cleanly.

Observed root cause: `kickstart -k` killed the npm/node wrapper (parent pid)
but left the tsx worker (child pid) alive as an orphan holding the pid file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(restart-voice-agent): argv-guard stale pid + tilde expansion

Two fixes per chi's review:
- Validate stale pid belongs to voice-agent.ts before killing (prevents
  accidentally killing unrelated processes that reused the pid)
- Expand leading ~ in WORKSPACE using ${HOME} so tilde-literal env values
  (e.g. SUTANDO_WORKSPACE=~/.sutando/workspace) don't break PID_FILE path

Stand: Echo Act IV Pro

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…persedes sonichi#1412/sonichi#1413) (sonichi#1434)

* fix(voice-agent): unified base-mode resolver (closes sonichi#1410, supersedes sonichi#1412/sonichi#1413)

Multi-substrate mode state (in-memory `meetingActive`, on-disk
`voice-mode.txt`, presenter HTTP server `:7877/presenter`, screen-companion
`activeMode`) was the root cause of gemini-3.1-flash-live-preview
fabricating `[System: Produce ZERO audio. Call NO tools.]` directives as
spoken output at session connect. Without an explicit base-mode
declaration, the model inferred meeting-mode silence from co-present-
flavored context.

PR sonichi#1412 added a per-activation `baseModePin` to screen-companion;
PR sonichi#1413 added a `CURRENT BASE MODE:` inline string to the system-prompt
instructions array. Both were partial — `baseModePin` only fires after
the model has already streamed for seconds, and the inline string
depends on ad-hoc branching kept in sync at every call site.

This PR extracts the mode logic into `src/voice-mode-resolver.ts` — a
pure, side-effect-free module that reads all substrates once and returns
a canonical `ModeState { mode, marker, isPresenter, isMeeting }`. The
`[BASE MODE: ...]` marker is injected at three points so the model sees
its mode explicitly before any other system text:

  1. Fresh-greeting (session-connect)
  2. Reconnect-greeting (resume)
  3. Instructions array (system_instruction, authoritative)

Priority: presenter > meeting > active. Screen-companion remains a
sub-mode that overlays the base (no change to skills/screen-companion/).

Unit tests cover: priority order, every-mode-emits-marker invariant
(regression against the empty-string return that `getPresenterStateMarker()`
defaulted to), and presenter-vs-meeting precedence when both substrate
states are set.

Stand: Echo Act IV (Mini)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(voice-mode-resolver): clarify substrate read/write contract

Per Lucy's review on PR sonichi#1434: tighten the source-doc to make explicit
WHY the resolver reads 2 of the 4 substrates listed. The other 2 are
not independent state:

- voice-mode.txt is a write-only output mirror of meetingActive
  (read by web-client/Sutando.app/discord-voice-server; never read
  back by voice-agent itself).
- screen-companion `activeMode` is an orthogonal sub-mode that overlays
  the base; conflating the two axes would lose information.

No behavior change; comment-only.

Stand: Echo Act IV (Mini)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lent no-op (sonichi#1428)

* fix(slack): surface silent core fails via task timeout + remote watchdog DM

Two independent failures stacked in the 2026-06-02 outage: the core session
crossed the 1M extended-context threshold and looped on the "usage credits
required" API error (no turn ever completed), and the Slack path has no
failure feedback — so the user just saw silence mid-task.

1. slack-bridge: per-task timeout. pending_replies now records submitted_at;
   result_watcher posts a one-time "still working / may have hit a context or
   usage-credit limit" reply after SLACK_TASK_TIMEOUT_SEC (default 600s,
   mirrors task-bridge.ts). The entry is KEPT after notifying, so a late real
   result is still delivered. 0 disables.

2. health-check: --notify-slack DMs the owner directly via the Slack Web API
   (urllib, no slack_bolt dep). Crucially NOT gated on _any_core_alive():
   core_heartbeat.py runs as its own process, so a wedged Claude loop still
   "looks alive" and emit_task stays quiet — but core-proactive-loop flips to
   warn and this fires anyway. Token from $SLACK_BOT_TOKEN or $REPO/.env (so
   the minimal-env launchd job works without a secret in the plist); owner
   from access.json. Same per-set-hash 1h-cooldown dedup as the macOS surface,
   on a separate state file; dedup recorded only on successful send so a
   transient Slack outage doesn't suppress the alert. Benign on-demand warns
   (discord-voice/conversation-server) are filtered out to avoid DM spam.

   Wired into the launchd fallback plist so outages self-report remotely.

Tests: tests/health-check-notify-slack.test.py (filter/dedup/failure-retry),
tests/slack-bridge-task-timeout.test.py (notify-once/retain/disable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): self-heal the 1M usage-credit-gate wedge + fix watchdog silent no-op

Builds on the prior watchdog commit (surface silent fails via Slack timeout +
remote DM). That made the 2026-06-02 outage VISIBLE; this makes it impossible to
stay silent and lets the core self-heal — without disabling 1M context (most
users, esp. on subscriptions, have it; on Max/Team it's gateless).

Root cause (confirmed against Claude Code v2.1.161 docs): an unattended core in
1M extended context can hit the interactive `/usage-credits` gate, which CANNOT
be pre-authorized — no settings key, env var, or CLI flag exists; the enable is
per-session/account-side only. The core then loops on the API error while its
heartbeat process keeps ticking, so it "looks alive" and drains nothing.

1. health-check: FIX the watchdog's silent no-op. _slack_token_from_env_file
   read only $REPO/.env, but the bridge keeps SLACK_BOT_TOKEN in
   ~/.claude/channels/slack/.env (startup.sh sources exactly that). On a stock
   install creds resolved to None and the DM never fired — the watchdog looked
   installed but was mute. Now reads the channel .env first, then $REPO/.env.

2. health-check: NEW --recover-core. When the core is alive-but-wedged (oldest
   queued task older than RECOVER_WEDGE_SEC while the heartbeat ticks — the
   precise loop signature, and it catches the idle-wedge that
   check_core_proactive_loop misses since that only flags status="running"),
   auto-restart via scripts/start-cli.sh --restart — the one mechanism we own.
   A restarted session re-clears the gate by itself (the /usage-credits enable
   persists account-wide once set), and queued task files survive the restart,
   so 1M stays the default and no work is lost. Heavily guarded because
   restarting a 24/7 agent is consequential: sustained-wedge confirm window,
   30min cooldown, 3/hr give-up cap (then DMs "gave up"), never-from-inside-core
   (launchd's own process + start-cli.sh's own guard), and a DM before each
   restart so recovery is never itself silent. Clean no-op when healthy.

3. Graceful degradation, opt-in: start-cli.sh honors $SUTANDO_CORE_MODEL. The
   first restart of an episode keeps 1M; if the wedge recurs the next restart
   pins --model opus (standard 200K, no gate) so the agent keeps WORKING instead
   of re-wedging. Default unchanged — 1M is never disabled for everyone.

Wired --recover-core into the launchd fallback job (its own process, every 5min)
so the core self-reports AND self-heals off-machine.

Tests: tests/health-check-recover-core.test.py (10 cases: confirm window,
cooldown, escalation, give-up cap + dedup, failed-restart retry, just-booted and
dead-core guards). tests/health-check-notify-slack.test.py +case i (token read
prefers channel .env, falls back to $REPO/.env, empty when absent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): address @sonichi PR sonichi#1428 review — 3 blockers + lock + tests

- B1 (slack-bridge `_check_task_timeouts`): set `timed_out=True` only AFTER a
  successful `_send_reply`, not before. A single Slack hiccup previously left
  the flag True forever (`continue` skips it next pass) so the user never saw
  the timeout warning — recreating the silent no-op this watchdog fixes. Failed
  sends now retry on the next pass.

- B2 (recover_core_if_wedged): capture the wedge-restart DM result. Recovery
  still restarts when the DM fails (recovery > notification — don't leave the
  core wedged because Slack is down), but records `last_restart_dm_sent` in the
  state file and logs to stderr/launchd, so a restart is never invisible. Same
  record-only-on-success treatment for the give-up DM.

- B3 (recover_core_if_wedged): wedge detection no longer equates "old task" with
  "wedged". `_oldest_pending_task` now returns a (name|mtime identity, age); the
  recovery requires the SAME oldest task to persist across the confirm window —
  a draining backlog surfaces a different oldest each pass and resets — AND that
  core-status.json did NOT advance (`_core_status_ts`): a core making progress
  on a long single task isn't restarted out from under itself. Residual (a long
  task that never heartbeats status) is documented and bounded by cooldown +
  give-up.

- Suggestion: serialize the load→decide→restart→save critical section under an
  exclusive non-blocking flock on `<state_file>.lock`, so a manual --recover-core
  and the launchd job firing together can't both clear the cooldown and issue
  duplicate restarts. Second caller no-ops with {"action":"locked"}.

- Nit (test gaps): tests/health-check-recover-core.test.py +cases k (draining
  backlog never restarts), l (status-progress resets; frozen status still
  fires), m (held lock → locked), n (failed DM still restarts, records
  dm_sent=False). New tests/start-cli-model-pin.test.py (default→no --model;
  SUTANDO_CORE_MODEL=opus→`--model opus` before --remote-control).
  tests/slack-bridge-task-timeout.test.py +test_send_failure_retries (B1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate (sonichi#1437)

* fix(schedule-crons): no inline-fire on registration + per-cron queue gate

Two bootstrap-storm mitigations bundled (M1 + M2 from the 2026-06-03
diagnostic on the 16:52Z 12-prompt avalanche).

**M1 — SKILL.md step 3 ambiguity.** The line "If prompt_skill is set,
invoke it as /skill-name" reads as "fire the skill immediately." When a
running session re-invoked /schedule-crons at 16:52Z, the prior session
inline-fired /morning-briefing + 5 cron-body prompts at one instant,
dropping 8 spurious prompts atop legit watcher TASK_FILE events. The
long-running session (9.5h old) drowned and ended at 16:54 without
processing two queued owner DMs.

Step 3 now explicitly says: pass `prompt: "/skill-name"` to CronCreate;
never invoke the skill or run the prompt body inline. Crons fire at
their scheduled cron expression, never on registration. Past-bug
reference embedded so future readers see the consequence.

**M2 — per-cron task-queue gate (defense in depth).** New helper
`scripts/cron-gate.sh <reason> <command...>` defers when any
`task-*.txt` is pending in `<workspace>/tasks/` (top-level only;
`archive/` and `processed/` subdirs don't count). Workspace resolves via
the M0 helper if present, else legacy `${SUTANDO_WORKSPACE:-...}` so
the script works on both main and the staging-workspace-revamp branch.

crons.example.json wraps every non-loop entry (morning-briefing,
daily-insight, pending-questions, sync-memory, obsidian-dream,
learned-skills-scan) with cron-gate. main-loop (/proactive-loop) is
exempt — it IS the owner-task handler; gating it would deadlock.

Tests: tests/cron-gate.test.sh — 7 cases (empty-queue runs / queued
defers / missing dir / archive+processed don't count / non-task files
don't count / usage error exits 2 / wrapped exit code propagates).
All 7 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(schedule-crons): unwrap daily crons from cron-gate; pin gate to sub-daily

Per Lucy's non-blocking review on sonichi#1437 (Discord #dev, 17:37:48Z): wrapping
the daily crons (morning-briefing 06:57, daily-insight 06:50, obsidian-dream
03:37, learned-skills-scan 07:30) means a single deferral skips the function
for 24h — a missed briefing day, missed dream pass, etc. — because there's no
near-term retry tick. For sub-daily crons (`*/30` pending-questions and
sync-memory), the next natural fire covers a skipped one within 30 min.

M1's no-inline-fire rule already kills the avalanche on registration. M2's
queue-gate is defense-in-depth and only earns its keep on high-frequency
crons where the avalanche-risk × queue-collision probability is meaningful.

Changes:
- `crons.example.json`: unwrap `cron-gate.sh` from the 4 daily entries
  (morning-briefing, daily-insight, obsidian-dream, learned-skills-scan).
  Keep wrap on the 2 `*/30` entries (sync-memory, pending-questions).
- `SKILL.md` "When to gate" decision rule: NEVER for main-loop, YES for
  sub-daily (`*/N`/hourly), NO for daily-or-less-frequent. Cites Lucy's
  catch so future cron additions know the rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stando-ui-src is checked out inside this repo, whose pnpm-workspace.yaml
(packages: ['.']) made `pnpm install` in stando-ui-src attach to stando's
already-installed workspace and skip stando-ui's own deps. `tsc -b` then ran
stando's TypeScript 6 (baseUrl -> fatal TS5101) with no vite installed, so the
UI build failed and the release aborted before signing. --ignore-workspace
installs stando-ui's own lockfile (TS 5.6 + vite) instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kills-community (sonichi#1441)

sonichi/sutando-skills is a private fleet-sync repo. New public community
repo sonichi/sutando-skills-community is the correct destination for
standalone skill contributions.

Stand: Echo Act IV Pro
SirEntropy and others added 17 commits June 4, 2026 17:36
Sync 203 upstream commits (2327fba..b0a29f0) into the fork. 60 conflicts
hand-resolved across src/, skills/, and tests/; full suite green
(TS 387/387, Python 86/86, tsc clean).

Key resolution decisions
- Path style: kept the fork's state-paths.js/state_paths.py + statePath()
  helpers in voice-agent, task-bridge, inline-tools; adopted upstream's
  status_read_path()/WORKSPACE_DIR in dashboard, agent-api, health-check
  (their clean code already used it; status_read_path falls back to the
  legacy root path so it's backward-compatible).
- conversation.log stays at <workspace>/ root (fork) — not logs/ — matching
  task-bridge's statePath('conversation.log') writer + import scripts.
- Conversation store: adopted upstream's per-surface schema (sonichi#1052) +
  recordToolCall real-time path; kept the fork's lazy node:sqlite require.
- discord-bridge / voice-agent / conversation-server / discord-voice-server:
  union of the fork's cloud-telemetry/billing + multi-provider (OpenAI)
  features with upstream's parse_markers refactor, discord-voice join
  trigger, observe-talk duck relay, config-file voice config, and bot/peer
  enforcement.
- Adopted upstream's task-file injection-safety format (task: last) +
  str()-normalized owner resolution + diagnose.py sonichi#1357 schema fix.
- workspace_default.resolve_workspace(): adopted upstream option-B (sonichi#1169) —
  no per-resolve auto-migrate; emits the legacy-state notice instead.
- Kept deleted: src/web-client.ts (folded into voice-agent, PR-A).
- Kept the fork's cross-node-sync skill (upstream removed it in sonichi#1108).

Flagged for follow-up
- Upstream's web-client.ts security scrubs (CodeQL #49/50/51) should be
  verified present in the folded voice-agent HTTP code.
- inline-tools.ts allowlisted in cwd-lint (repo-relative process.cwd() for
  skills/notes); memory-backup.py added to host-cli KNOWN_VIOLATIONS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge upstream/main (sonichi/sutando) into dev-client
…send-allowlist

Three beta-feedback fixes ahead of the 0.4.0 client release. All reported
before the upstream OS sync; verified still-broken on dev-client.

- 163f8468 [critical]: bridge launchd templates now set RunAtLoad:false and
  drop KeepAlive.SuccessfulExit:false, leaving the token-.env PathState as
  the sole gate. Previously launchd OR'd PathState with SuccessfulExit:false,
  so an unconfigured channel restart-looped the bridge (exit(1) -> respawn
  -> exit(1); the report saw ~37k log lines/day). Fixed for discord, telegram
  and slack templates.

- 77dc1b98 [high]: slack/discord/telegram reply-watchers skip-and-retry on an
  empty result read for a 2s grace window before claiming + archiving, so a
  result file caught mid-`> file` write is no longer archived empty (silently
  dropped reply). Genuinely-empty results still fall through after the grace
  so nothing wedges pending forever.

- 2033745d [high]: send-allowlist gains workspace data/ and broadens the temp
  prefixes from /tmp/sutando- to /tmp/, /private/tmp/, /var/folders/ (canonical
  src/send_allowlist.py + the telegram/slack mirrors), so ad-hoc /tmp working
  files (e.g. /tmp/report.xlsx) are deliverable. Intentional owner-scoped beta
  widening; the realpath sanitizer still blocks symlink/.. escapes to $HOME +
  system paths. The guard tests' escape fixtures were re-pointed at $HOME//etc
  so they keep genuinely testing the boundary.

Tests: 24 affected suites pass (allowlist guards, reply-watchers, plist/launchd
single-instance, dm-result, markers). No prompt/behaviour changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…list fixtures

519fabd's broadening added "/var/folders/" to SEND_ALLOWED_PREFIXES, but that
entry is dead: realpath collapses /var -> /private/var on macOS (so the bare
prefix never matches realpath output) and Linux has no /var/folders at all.
Drop it; keep "/tmp/" + "/private/tmp/" — the two realpath forms that actually
cover /tmp on Linux + macOS — across send_allowlist.py and the telegram/slack
mirrors.

Also fix tests/slack-bridge-allowlist.test.py, which passed on macOS but failed
on Linux CI (run 26993282383): its "disallowed" + symlink fixtures used
tempfile-default dirs, which land in /tmp on Linux (now allowed) but
/var/folders on macOS (unmatched), so the same assertion diverged by platform.
Point them at $HOME — outside the allowlist on both OSes — matching the escape
fixtures already updated in the send-allowlist + discord file-send tests, and
clean the fixture up. Documented-set guards updated to {/tmp/, /private/tmp/}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…8b4b)

Task writers emit a UTC `timestamp:` (toISOString / gmtime); the core read it as local and mislabeled ETAs (reported: said 9:50pm vs correct 2:50pm — a 7h UTC offset). Add a `local_time:` header (system-local or OWNER_TZ, %Z-labeled) next to it in all 5 writers so the agent has an authoritative, zone-labeled now.
…(19fa7b9f)

The triage command ran 'gws gmail +triage 2>/dev/null', swallowing 'command not found' when the gws CLI isn't installed/authed, so installing the skill silently did nothing. Drop the stderr redirect and add a 'command -v gws' guard that tells the user how to connect, plus a Prerequisite note.
No 'gws-gmail' skill exists; the agent following these docs looked for a missing skill. Point at the real skills: email-triage (full) / gws-gmail-voice (inline).
PreCompact/Stop hooks hardcoded ~/Desktop/sutando-conversations and ~/Desktop/sutando/src/* — a workspace-contract violation and part of the repeated-Desktop-TCC-prompt cluster. Use $SUTANDO_WORKSPACE (mkdir -p for the conversations dir).
…pt (9c5e68f6)

Production runs the core as launchd com.sutando.core-agent (KeepAlive); its claude has no --name, so start-cli.sh's pgrep 'claude --name sutando-core' missed the kill and its new-session raced launchd's respawn — the menu item did nothing. restartCore() now runs 'launchctl kickstart -k gui/<uid>/com.sutando.core-agent' and falls back to the script for the dev workflow.
…2fec9b2)

Three identically-labeled 'Get token' links; a user clicked Telegram's intending Slack. Include channel.displayName: 'Get Slack token' / 'Slack setup guide'.
…tion (9f41f69d, 3626e281)

Two critical-feedback behaviors: the agent silently swapped a user's script for its own and shipped a defective customer report (9f41f69d), and it edited code when only asked to inspect logs (3626e281). Broaden the confirm-first rule to cover modifying files when only asked to inspect (stay read-only when examining), and add an Operating-Style fidelity clause: use the user's specified method and surface any deviation before the output is used.
The upstream sync (#108) dropped the early .env/GEMINI_API_KEY pre-flight
block but kept its trailing consumer line, gluing `if [ $missing -eq 1 ]`
right after the stand-identity.json block where $missing is unset (its real
assignment is ~80 lines later). Every startup printed `[: -eq: unary
operator expected` to stderr; control flow survived only because it's an
if-condition under set -e. The fork's preflight (init.sh --preflight) is
intentionally non-blocking, so the late `missing` check is the only one
wanted. Delete the orphan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-root path

The upstream sync resolved conversation.log inconsistently across files:
task-bridge.ts, voice-agent.ts and import-conversation-log.py kept the fork's
workspace-root path (statePath('conversation.log')), while the phone and
discord-voice surfaces picked up upstream's logs/conversation.log. Result:
phone + discord-voice transcripts were siloed from task-bridge's cross-agent
context reader, voice-agent's first-run detection, and import --reload.

Auto-migration is disabled (sonichi#1169), so the root file is canonical and holds
the live data. Repoint the three voice surfaces — phone getSafeContext +
cleanupCall, discord-voice CONVERSATION_LOG, and catchup-after-startup's
reader — back to <workspace>/conversation.log. Non-destructive; restores the
0.4.0-rc.1 preview behavior where phone and task-bridge already agreed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
skills/install.sh (the ~/.claude/skills symlinker) only ran at app-install (LaunchAgentInstaller) and dev startup.sh — never from the launchd-managed core. So skills added after install (upstream sync, skill-installer, manual) stayed invisible to the running core until a full startup, requiring a manual ln -s. run-core-agent.sh now runs the idempotent linker before launching claude, so every core (re)start (incl. Restart Core CLI + launchd KeepAlive) picks up newly-added skills. Claude Code loads skills at process start, so this is the correct granularity.
… (10b961d6)

CLAUDE.md/AGENTS.md '## Tutorial' tells the agent to Read notes/first-time-tutorial.md and deliver it section-by-section, but the file was never shipped (not in repo, bundle, or workspace), so saying 'tutorial' read a nonexistent path and nothing happened. Ship the content as a seed at skills/startup/first-time-tutorial.md (committed + bundled via skills/), and have run-core-agent.sh + startup.sh copy it into <workspace>/notes/first-time-tutorial.md if absent (never overwriting a user's edited copy). notes/ is per-user/gitignored, so a copy-on-startup seed is the right mechanism.
…p instead of offloading

Three fixes from the beta feedback review (fb79f790, 6bad56a2):

- Location (fb79f790): deal-finder shipped a hard-coded ZIP (94566) that the
  agent read as the user's home, reporting a Sydney user as living in
  California. Blank the shipped ZIP + guard scan.py until it's set, and carry
  the IANA timezone (e.g. Australia/Sydney) in the local_time task header so
  the agent grounds location on the system zone, not a skill's search config.

- Gmail completion detection (6bad56a2): add connect-gmail.py. `--check`
  verifies state via `gws auth status` + a live read-only Gmail probe with no
  side effects; the drive flow blocks through sign-in then verifies. The agent
  can now KNOW sign-in succeeded instead of asking the user to "confirm on
  your end" (user had signed in 3x undetected).

- Offloads work / asks what next (6bad56a2): CLAUDE.md guardrail to drive
  setup to completion and verify the end state; update email-triage / README /
  built-in-tools to the verified flow (drop the wrong `gws auth setup --login`
  prerequisite).

Verified: tsc --noEmit clean, py_compile clean, scan.py guard exits 2,
connect-gmail.py --check exits 1 with no side effects. ASR/language left as-is
per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The upstream merge (#108) added yaml@^2.9.0 to package.json and the npm
lockfile but not pnpm-lock.yaml, so release.yml's `pnpm install
--frozen-lockfile` step failed (ERR_PNPM_OUTDATED_LOCKFILE) — a CI-only
path that build-app.sh doesn't exercise, so it passed local build but
broke the v0.4.0 release run. Regenerate the pnpm lockfile. Verified with
the exact CI command (pnpm install --frozen-lockfile → exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — PR #71 "Dev client"

This PR has no description, which makes reviewing 353 files very hard. I pieced together the intent from the code, but a short summary is required before merge. At minimum: what problem does this solve, and what is the intended merge sequence?


What I can see from the code

Client extraction — client/ (97 files, -5455 lines) is removed and the UI contract is externalized to docs/WIRE.md. The wire document is clear and well-structured — good work. Confirms the UI moved to a separate repo (stando-ui).

discord-voice integration — src/discord-bridge.py adds a best-effort import for the join_trigger skill, with a clean try/except fallback so the bridge still boots without the skill installed. This follows the architecture rule correctly (thin hook in core, feature logic in skill).

CLAUDE.md improvements — the read-only-on-inspect rule and the local_time: timezone grounding changes are well-reasoned. No objections.

New core files — src/discord_config.py, src/single_instance.py, src/task_archive.py, src/result_channel_key.py, src/voice-config.ts, etc. These look like legitimate shared infrastructure, but without a description I cannot verify these belong in src/ vs a skill.

Tests — 56 new test files, +11182 lines. I spot-checked tests/agent-api-result-archive.test.py and tests/discord_config.test.py — both are real, isolated tests with proper workspace isolation. Good.

No test for src/single_instance.py — the one new src/ module missing test coverage. Not blocking, but worth adding.


Requests before merge

  1. Add a PR description. One paragraph: what the PR does, the stando-ui repo relationship, what changed in core and why.
  2. Confirm CI passes (green check on this PR — .github/workflows/ci.yml runs the full test suite).
  3. Optional: test for src/single_instance.py.

The code I reviewed is solid. Holding APPROVED pending a description so future reviewers (and git log) have context.

SirEntropy and others added 5 commits June 11, 2026 16:49
Existing 0.4.x native users update via Sparkle; the 0.5.0 Electron app updates
via electron-updater — different feeds, no auto-handoff. This final native build
shows signed-in returning users a one-time, dismissible prompt that downloads the
0.5.0 dmg via the cloud download proxy (/api/download/electron, authenticated
with the app's Bearer token) and opens it for a drag install. The shared bundle
id + ~/.sutando/workspace carry over their keys, sign-in, and memory.

- MigrationPrompt.swift: NSAlert + .migrated-0.5.0 sentinel + authenticated
  downloadTask + NSWorkspace.open; dashboard fallback on failure / 403.
- main.swift: fire it 1s after launch, returning-user branch only.
- build-app.sh: add the new source to SWIFT_SOURCES.

Ships as v0.4.1 (CI sets the version from the tag). The cloud proxy's electron
channel + Bearer auth must be deployed before testers run this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…10.x

pnpm 10 on the CI runner drifted to a version that no longer matches a github:
dep in onlyBuiltDependencies by bare name — it needs the full resolved spec, so
`pnpm install --frozen-lockfile` failed ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED and
broke v0.4.1 (v0.4.0 built before the drift). Add the full codeload spec next to
the bare name. onlyBuiltDependencies isn't recorded in pnpm-lock.yaml's settings,
so --frozen-lockfile is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Twilio gate

- credential-proxy: streaming-safe idle timeout so a dead upstream socket
  (sleep/wake, wifi drop) aborts to a retryable error instead of hanging the
  core forever — the indefinite "agent went silent" freeze.
- phone: VAD keepalive backfills Twilio silence-suppressed gaps with 20ms zeroed
  PCM16 frames so Gemini's server VAD re-arms between turns (AG2-402, deaf after
  first turn). PHONE_VAD_KEEPALIVE=0 disables.
- startup.sh: gate the phone server on $TWILIO_ACCOUNT_SID (sourced from the
  workspace .env) rather than grepping the repo .env (AG2-400).
- scripts/ensure-bridge-deps.sh: create a workspace venv with slack_bolt +
  discord.py so the Slack/Discord bridges start on a fresh Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ects

Client safety net for the managed-voice ~10-min death (server-side root-cause
fix shipped in agent-universe 90b427a). If a Gemini Live reconnect ever fails
with a token-expiry reason, classify it as 'token_expired' (non-retryable) and
re-mint via a launchd restart -- the same idiom as the Settings key-reload
path -- instead of spinning on 30s connect timeouts forever. BYOK keys don't
expire, so they're exempt.

- voice-error-classifier.ts: new 'token_expired' category + match pattern
- voice-agent.ts: handleClose exits for re-mint on token_expired (managed only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
conversation-server now points the configured number's VoiceUrl + StatusCallback
at the live tunnel via the IncomingPhoneNumbers REST API on every start. Removes
the manual Twilio-console paste AND self-heals ngrok's per-reboot URL change --
the #1 phone-setup friction. Best-effort: a failure logs the manual fallback and
never blocks startup. Opt out with TWILIO_AUTO_WEBHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.