Repository navigation
Dev client - #71
Dev client#71jgluck96 wants to merge 269 commits into
Conversation
…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>
…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
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
left a comment
There was a problem hiding this comment.
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
- Add a PR description. One paragraph: what the PR does, the stando-ui repo relationship, what changed in core and why.
- Confirm CI passes (green check on this PR —
.github/workflows/ci.ymlruns the full test suite). - 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.
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>
No description provided.