Skip to content

Upstream sync phase 2 — CP-4 through CP-7 + Slack settings UI - #946

Closed
jsun-m wants to merge 127 commits into
sonichi:mainfrom
AG2Platform:merge/upstream-cp4-7
Closed

jsun-m wants to merge 127 commits into
sonichi:mainfrom
AG2Platform:merge/upstream-cp4-7

Conversation

@jsun-m

@jsun-m jsun-m commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Second batch of the incremental upstream sync (sonichi/sutando → AG2Platform/stando), following PR #17 (CP-0–3). Four feature-grouped checkpoints plus a Slack-configuration UI.

#791 (conversation-store SQLite mirror) is intentionally deferred to its own checkpoint. The final git merge upstream/main happens after all checkpoints land.

33 files changed, +3767/−105.

Test plan

  • npx tsc --noEmit clean (root + client)
  • npm test — 176 pass, 0 fail
  • Slack bridge Python tests pass
  • swiftc -parse clean on the changed Swift files
  • Rebuild the .app, confirm voice + conversation screen come up
  • Settings → Channels → Slack: paste tokens, confirm the bridge auto-starts
  • Vision control server responds on :7848
  • Smoke-test the voice agent (pidfile lock, artifact cache, recent_context)

🤖 Generated with Claude Code

SirEntropy and others added 30 commits May 5, 2026 08:40
Adds DISTRIBUTION.md (the 5-phase plan to ship as a signed macOS app
backed by the agent-universe cloud control plane) and lands Phase 0:
the path/port/env/launchd indirection that lets the .app bundle redirect
runtime state out of the repo cwd without changing the dev workflow.

- statePath() / state_path() helpers in util_paths.{ts,py}: resolve to
  $SUTANDO_HOME/<name> when set, else <REPO_DIR>/<name>. Callers updated
  in task-bridge, voice-agent, web-client, inline-tools, voice-context,
  agent-api, dashboard, health-check, screen-capture, init.sh, startup.sh.
- Port overrides: DASHBOARD_PORT, AGENT_API_PORT, SCREEN_CAPTURE_PORT,
  SUTANDO_SCREENSHOT_DIR (defaults unchanged).
- Layered .env loader (src/load-env.ts): repo .env first, then
  $SUTANDO_HOME/.env with override. voice-agent + conversation-server
  switched to it; startup.sh sources $SUTANDO_HOME/.env so Python
  services inherit it.
- launchd plist templates under app/LaunchAgents/ for the seven services
  the .app bundle will install (voice-agent, web-client, dashboard,
  agent-api, screen-capture, credential-proxy, phone-conversation).
  Templates use {{REPO_DIR}}/{{NPX_BIN}}/{{PYTHON_BIN}}/{{SUTANDO_HOME}}
  placeholders the Swift launcher substitutes at install time.
- Vendor bodhi-realtime-agent: github commit pin (#ffec0cd) → npm 0.1.5
  for reproducibility under notarization.

Backwards compat: every change defaults to current behavior when
SUTANDO_HOME is unset, so `bash src/startup.sh` from a fresh clone
keeps working. Verified: tsc clean, 150/150 tests pass, smoke-tested
init.sh + util_paths + load-env in both unset and set-to-tmpdir modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the existing dev environment in a distributable macOS .app bundle
without changing any runtime behavior. Internal-release-ready: builds,
ad-hoc signs, packages into a 93MB drag-to-Applications DMG. Real
Developer ID signing + notarization are Phase 2.

- app/Info.plist: com.sutando.app, TCC usage descriptions for Screen
  Recording / Accessibility / Microphone / Camera / Contacts / Calendar /
  Reminders / Desktop+Documents+Downloads, custom URL scheme `sutando://`
  for cloud-account login callback, LSUIElement (menu-bar only).
- app/Sutando.entitlements: hardened-runtime entitlements covering the
  apple-events surface used by inline-tools/browser-tools, network
  client+server for the seven local services, JIT + library-validation-off
  for bundled Node + Python.
- app/build-app.sh: produces Sutando.app. Compiles main.swift +
  LaunchAgentInstaller.swift; copies Info.plist, LaunchAgent templates,
  icon; stages src/, skills/, package.json, tsconfig, CLAUDE.md, assets/,
  and node_modules/ (~212MB) into Contents/Resources/repo/; ad-hoc signs
  with hardened runtime + entitlements.
- app/build-dmg.sh: hdiutil-based UDZO compressed DMG (93MB) with
  /Applications symlink for drag-to-install + READ ME FIRST.
- app/README.md: build + install + uninstall + prereqs (internal-release
  v1 relies on Homebrew node 22 + fswatch + system python3 because
  Homebrew node depends on Homebrew dylibs; LaunchAgentInstaller
  resolves system fallback paths automatically).
- src/Sutando/LaunchAgentInstaller.swift: reads Resources/LaunchAgents/
  *.plist.template, substitutes {{REPO_DIR}}/{{NPX_BIN}}/{{TSX_BIN}}/
  {{NODE_BIN}}/{{NODE_BIN_DIR}}/{{PYTHON_BIN}}/{{SUTANDO_HOME}}/
  {{LOG_DIR}}, writes to ~/Library/LaunchAgents/ and runs `launchctl
  bootout && bootstrap`. Falls back to /opt/homebrew/bin → /usr/local/bin
  → /usr/bin when bundled runtime isn't present (covers the Phase 1
  no-bundled-Node path).
- src/Sutando/main.swift: workspace prefers Bundle.main.resourcePath/repo
  when running from .app, falls back to the existing 8-dir-walk for
  raw-binary dev. New stateRoot lazy var reads $SUTANDO_HOME or
  workspace. Updated `/results`, `/tasks`, `/state`, `/logs` call sites
  to use stateRoot. Single-instance pgrep pattern broadened to
  (Sutando|MacOS)/Sutando$ to match both .app and raw-binary layouts.
  logToFile creates the log dir defensively. New menu items: Install /
  Uninstall Background Services.
- src/startup.sh: swiftc invocation now compiles both Swift files
  (main.swift + LaunchAgentInstaller.swift), with rebuild detection
  across all source files, not just main.swift.
- src/health-check.py: pgrep pattern for Sutando.app updated to
  (Sutando|MacOS)/Sutando.

Verified: tsc clean, 150/150 tests pass; .app boots both bundled
(workspace=Sutando.app/Contents/Resources/repo, SUTANDO_HOME honored)
and raw-binary (workspace=repo cwd); DMG mounts with correct layout;
LaunchAgent renderer round-trips templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the production-release pipeline: Developer ID signing, Apple
notarization, Sparkle 2 auto-update, EdDSA-signed appcast, and a
GitHub Actions workflow that runs the whole flow on a macOS M-series
runner. Default ad-hoc dev build is preserved — Sparkle and Developer
ID are gated by env vars / GitHub secrets, so this PR doesn't change
local-dev behavior.

Phase 2.1 — signing-aware build:
- app/build-app.sh accepts SIGNING_IDENTITY (default "-" for ad-hoc).
  Real Developer ID signs nested Sparkle helpers (Updater.app,
  Installer.xpc, Downloader.xpc, Autoupdate, Sparkle.framework itself)
  before the outer bundle.
- app/notarize.sh: xcrun notarytool submit + wait + stapler. Accepts
  either NOTARYTOOL_PROFILE (stored keychain credentials) or the
  APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD trio.

Phase 2.2 — Sparkle:
- src/Sutando/SparkleUpdater.swift: thin façade with both real
  (#if ENABLE_SPARKLE) and no-op stub implementations sharing the
  same API. main.swift can reference SparkleUpdater unconditionally.
- main.swift: instantiates SparkleUpdater + adds "Check for Updates…"
  menu item.
- Info.plist: SUFeedURL (channel-templated at runtime),
  SUPublicEDKey (placeholder — replace with real key from
  generate-keys.sh before shipping), SUEnableAutomaticChecks,
  SUScheduledCheckInterval=3600, SUEnableInstallerLauncherService.
- build-app.sh: ENABLE_SPARKLE=1 + SPARKLE_FRAMEWORK pointing at the
  vendored framework triggers -F + -framework Sparkle + rpath
  @executable_path/../Frameworks, copies Sparkle.framework into
  Contents/Frameworks/, signs nested helpers explicitly.
- src/startup.sh: swiftc invocation now includes SparkleUpdater.swift
  alongside main.swift + LaunchAgentInstaller.swift.

Phase 2.3 — appcast tooling:
- app/sparkle/fetch-sparkle.sh: downloads Sparkle 2.9.1 (pinned)
  release tarball, lays out app/vendor/Sparkle.framework + helper
  binaries (sign_update, generate_keys, generate_appcast) in
  app/vendor/sparkle-bin/.
- app/sparkle/generate-keys.sh: one-time EdDSA keypair generation.
  Private key stays in macOS keychain; prints the public key for
  Info.plist.
- app/sparkle/generate-appcast.sh: produces signed appcast.xml for a
  release dir. Channel-aware (--channel internal|beta|stable). Reads
  SPARKLE_PRIVATE_KEY_FILE for CI use.
- .gitignore: app/vendor/ + app/sparkle/sparkle.priv (key backup
  must never be committed; keychain is canonical).

Phase 2.4 — release CI:
- .github/workflows/release.yml: macos-14 (M-series) runner, triggered
  by tag push (v*) or manual workflow_dispatch with version + channel.
  Steps: checkout → Node 22 + npm ci → typecheck + tests → bump
  Info.plist version → fetch Sparkle → import Developer ID cert into
  ephemeral keychain → build .app → notarize .app → build .dmg →
  notarize .dmg → sign appcast → create GitHub Release. Required
  secrets documented in workflow header (currently all optional;
  build degrades gracefully to ad-hoc + unsigned-appcast warnings
  if absent).

Verified: tsc clean, 150/150 tests pass, default ad-hoc build still
produces a working .app, all shell scripts pass `bash -n` syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Connects the local Sutando.app to the agent-universe cloud control plane
shipped in Phase 3. Sign-in via the sutando:// URL scheme, token in macOS
keychain, device heartbeat, and three live meters (voice / TTS / image-gen).

4.1 — src/Sutando/CloudAuth.swift:
- Token + userId stored in macOS keychain (com.sutando.app.cloud).
- Mirror written to $SUTANDO_HOME/cloud-auth.json (mode 0600) so Node +
  Python services can read the same auth state without keychain access.
- Stable machineId persisted in $SUTANDO_HOME/device.json.
- Sign-in flow: nonce → /cli-login?challenge=…&via=urlscheme; cloud
  redirects to sutando://auth?token=…&userId=…&challenge=…; CloudAuth
  validates challenge against the on-disk pending file (5-min expiry),
  stores creds, fires /api/devices heartbeat.
- 401 from heartbeat auto-signs-out (token revoked server-side).

4.2 — src/cloud-client.ts:
- isCloudSignedIn() / cloudFetch() / recordEvent() / flush() — same
  shape as the dashboard's API expectations. Batched POST to /api/usage
  every 60s or 200 events, whichever first. QUEUE_CAP=5000 so a network
  outage during a long voice session can't OOM the agent.

4.3 — src/cloud_metrics.py:
- Synchronous record_event() for the lower-frequency Python meters.
  Fires on a daemon thread; failures silent.

4.4 — agent-universe /cli-login:
- Switched callback from localhost POST to sutando://auth redirect.
- Legacy ?port=… localhost path kept as a fallback.

4.5 — main.swift integration:
- Sign in / Sign out menu items, wired to CloudAuth.shared.
- NSAppleEventManager handler for the sutando:// scheme.
- 5-min heartbeat timer (initial + recurring) calling /api/devices.
- updateCloudMenuItems() flips visibility on each sign-in/out event.
- Trailing-comma swiftc fixes.

4.6 — instrumentation:
- voice-agent.ts: emit voice.gemini event from writeVoiceMetrics() with
  durationSeconds + sessionId + model + toolCalls metadata.
- cartesia-tts.ts: emit tts.cartesia event with audio duration computed
  from PCM byte count / (sample_rate * channels * bytes/sample).
- skills/image-generation/scripts/generate.py: emit image.gen or
  video.gen with units=1 + model name. Lazy import so the script still
  runs cleanly if cloud_metrics isn't on path.

build-app.sh + startup.sh: include CloudAuth.swift in the Swift sources
list. App still compiles + boots both bundled and raw-binary.

Verified: tsc clean, 150/150 tests pass, .app boots with stub
SUTANDO_CLOUD_URL, debug log shows clean startup.

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

Three post-plan additions surfaced by the first end-to-end test on a
fresh machine. Together they close the "non-technical user can install
and use" loop that the original plan punted on with "internal users
are devs".

Phase 4.7 — Settings + first-launch:
- src/Sutando/EnvFile.swift: round-tripping .env parser/writer that
  preserves comments + unknown keys + line order. Single-write atomic
  via tmp + replaceItemAt + chmod 0600.
- src/Sutando/Permissions.swift: TCC status checks (microphone via
  AVCaptureDevice, accessibility via AXIsProcessTrusted, screen
  recording via CGPreflightScreenCaptureAccess) + the
  x-apple.systempreferences:com.apple.preference.security?Privacy_*
  deeplinks for one-click "open the right pane".
- src/Sutando/SettingsWindow.swift: single-pane NSWindow with cloud
  account row, API key form (Gemini required + Cartesia + Notification
  email + 12 advanced fields under disclosure), permission rows with
  ✓/✗ indicators + Grant buttons, Background Services Install /
  Uninstall. 1.5s polling timer refreshes status while window is open
  so returning from System Settings reflects new grants.
- main.swift setupMainMenu(): builds an Application + Edit + Window
  menu so ⌘V routes to text fields. Without this, LSUIElement apps
  can't paste — found the hard way after the user couldn't paste
  their Gemini API key.
- main.swift "Settings…" menu item with ⌘, accelerator. First-launch
  detection: SettingsWindowController.needsFirstLaunchSetup checks for
  no Gemini key + no $SUTANDO_HOME/.firstrun-complete marker; on
  match, Settings auto-opens 300ms after launch.

Phase 4-bug — installer hardening:
- LaunchAgentInstaller.install() returned [String] and threw on the
  first launchctl bootstrap failure, leaving partial state on disk.
  Real-world: phone-conversation.plist has Disabled=true (paid tier),
  launchctl bootstrap returns "5: Input/output error" on Disabled
  plists, the throw aborted the loop after only 4 of 7 services
  installed.
- Replaced with LaunchAgentInstallSummary { installed, skippedDisabled,
  failed } collected per-plist; loop continues on individual errors.
- Added isDisabled() detector (regex on rendered template) — for
  Disabled plists, write the file (so user can manually enable later)
  but skip the bootstrap call.
- SettingsWindow + main.swift install handlers updated to display
  per-failure detail rather than "first error".

Phase 4-core-agent — Claude Code as launchd service:
- Original 7-plist set didn't include the Claude Code core agent that
  processes voice tasks. Voice agent's get_core_status returned "not
  running" because nothing started claude.
- src/run-core-agent.sh: bash wrapper that creates a tmux session
  detached (-d so no TTY required under launchd) running claude with
  --dangerously-skip-permissions /proactive-loop, polls
  tmux has-session every 30s, exits when session dies so launchd's
  KeepAlive recreates it. Self-checks for tmux + claude on PATH and
  sleeps 60s rather than crash-loop on missing prereqs.
- app/LaunchAgents/com.sutando.core-agent.plist.template: runs the
  wrapper with PATH extended to include /opt/homebrew/bin + ANTHROPIC_BASE_URL
  pointing at the credential proxy.
- LaunchAgents/README.md updated with the new entry.
- app/build-app.sh and src/startup.sh swiftc invocations include the
  three new Swift files.

Cosmetic: voice-agent.ts data/voice-metrics.jsonl now uses statePath()
so it lands in $SUTANDO_HOME/data/ instead of failing with ENOENT
when the bundled (read-only) repo is the cwd.

DISTRIBUTION.md: added a Status section near the top with a table
summarizing each phase + the operational gaps blocking real internal
release. Annotated every phase header with [DONE]/[PARTIAL]/[PENDING]
+ commit hashes. Replaced the "concrete work" list under Phase 1 with
what actually shipped (deviations called out: no Xcode project, Node
bundling deferred, first-launch wizard superseded). Added new sections
for Phase 4.7, Phase 4-bug, Phase 4-core-agent. Updated sequencing
table to reflect actual progress.

Verified: tsc clean, .app builds, both raw-binary and bundle path
resolution work, Settings opens auto on first launch when no Gemini
key set, install reports "Installed N services. Skipped …" without
aborting on Disabled plists.

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

Closes the gap between "internal release UX" and "public release UX": after
this commit the .app drag-installs and walks the user through setup with
zero Terminal touch except `claude auth login` (Anthropic owns that flow).

Four changes:

1. Bundled runtime — Node 22 LTS + tmux + libevent/ncurses dylibs (rpaths
   rewritten to @executable_path/../lib) + ncurses terminfo, all under
   Resources/runtime/. New app/bundle-runtime.sh handles the download +
   stage; build-app.sh runs it before staging the .app. Drops the Homebrew
   prereq for end users.

2. fswatch removed entirely. New watch-tasks.mjs + watch-tasks-stream.mjs
   use Node fs.watch with the same on-the-wire contract (TASK_FILE: /
   TASK_DETECTED:); the .sh files are thin `exec node` wrappers so every
   existing caller (CLAUDE.md, SKILL.md, Monitor invocations, pgrep -f)
   keeps working unchanged. Sweep covers startup.sh, init.sh,
   verify-setup.sh, migrate.sh, READMEs, code comments.

3. Web UIs hosted in-app via WKWebView (new WebWindow.swift). openWebUI()
   and openDashboard() no longer AppleScript-drive Chrome — they spawn
   native Sutando windows that proxy onto the existing launchd-managed
   :8080 / :7844 services. Mic auto-granted for localhost; cold-start
   retry handles the case where launchd hasn't bootstrapped the service
   yet.

4. Settings grew a Claude Code section (detect → install via NSTask →
   Sign-in via Terminal) and a 4-step first-launch stepper at the top
   (① API key → ② Claude Code → ③ Permissions → ④ Background services)
   that hides once .firstrun-complete is written.

Built artifacts: Sutando.app 350MB / Sutando.dmg 143MB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six fixes for things that broke on a clean macOS account install. All caught by
walking the install flow with state stashed aside (`Sutando.bak`):

1. Mic permission entitlement key — hardened-runtime apps must declare
   `com.apple.security.device.audio-input`; the sandbox-era
   `com.apple.security.device.microphone` alone makes tccd silently refuse to
   prompt ("Policy disallows prompt for ... access to kTCCServiceMicrophone
   denied"). Both keys now declared for forward/back compat.

2. Mic prompt suppression on LSUIElement apps — TCC prompts from .accessory
   activation-policy apps can be silently swallowed. `grantPermission` now
   bumps to `.regular` for the duration of the AVCaptureDevice request,
   reverts after. Reorders: fire the prompt FIRST, fall through to System
   Settings only if the user denies (opening Settings before the prompt
   steals focus and macOS suppresses prompts from non-frontmost apps).

3. Skills missing from `~/.claude/skills/` — the core-agent invokes
   `/proactive-loop` as a slash command, which claude resolves by looking
   in `~/.claude/skills/`. The .app installer never seeded that directory,
   so claude printed "Unknown skill: proactive-loop" and exited, sending
   the wrapper into a 30-second crash-loop. `LaunchAgentInstaller.install()`
   now runs the bundled `skills/install.sh` to symlink every shipped skill
   into the user's global skills dir.

4. Claude Code 2.1.x bypass-permissions warning — Claude now shows an
   interactive `1. No, exit / 2. Yes, I accept` prompt on every launch,
   even with `--dangerously-skip-permissions`. The default is "exit", so
   our wrapper would idle until claude timed out. `run-core-agent.sh` now
   sends `Down + Enter` 2 seconds after session start. Also kills any
   pre-existing tmux session on every wrapper start (a zombie session
   stuck on the warning would otherwise be treated as healthy by
   has-session and the send-keys would never fire). Pane output is now
   piped to `core-agent.pane.log` so the next bug like this is one tail
   away.

5. screen-capture-server "No such file or directory: 'screencapture'" —
   the launchd plist's PATH had `/usr/local/bin:/usr/bin:/bin` but the
   system `screencapture` lives at `/usr/sbin/screencapture`. Added
   `/usr/sbin` to the screen-capture plist template.

6. Screen Recording permission on launchd-spawned python3 — TCC tracks
   the launchd-spawned python as a separate identity from Sutando.app,
   so granting Screen Recording to the .app doesn't help the helper.
   `screenRecording.request()` now also pokes the running screen-capture
   service AND fork-spawns python3 with a one-shot `screencapture` so
   python3 lands in System Settings → Privacy → Screen Recording
   alongside Sutando.app — user toggles both in one trip.

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

Wire desktop-side emitters for the three new admin event streams the
cloud started accepting in Phase 4.11. Without this the funnel and
reliability views are empty no matter how many users sign in.

src/cloud-client.ts (Node)
- Stamp every recordEvent with appVersion (read from bundled Info.plist
  or package.json once at startup). Powers release-vs-release regression
  analysis on /admin/reliability.
- New: recordOnboarding(step), recordError({...}), startSession({kind}),
  endSession(id, {turnCount, toolsUsed, endedReason}). All fire-and-forget,
  silent no-op when signed out.

src/cloud_metrics.py — Python equivalents (record_error,
record_onboarding) for image-gen / Twilio / dashboard services.

src/Sutando/CloudClient.swift (new) + app/build-app.sh — Swift caller
for /api/onboarding and /api/errors. Reads cloud-auth.json the same way
CloudAuth does.

SettingsWindow.swift — emits 5 onboarding milestones at their natural
checkpoints (gemini_key_set, firstrun_complete on Save; claude_installed
when claudeCodePath() resolves; perms_granted when all 3 TCC checks pass;
services_installed on clean LaunchAgentInstaller pass). Per-launch
in-memory dedup keeps the 1.5s polling loop quiet; server's
(user_id, step) unique index is the source of truth. Per-failure
launchd.bootstrap_fail error events; fatal launchd.install_failed if
install() throws.

voice-agent.ts — onSessionStart opens cloud session row, onSessionEnd
PATCHes with turnCount + toolsUsed + mapped endedReason. onError +
voice-failure transport classifier emit error events.

conversation-server.ts — twilioCall opens phone.outbound session keyed
by callSid (promise stored in map to handle hangup-before-start race);
twilioHangup awaits the promise and PATCHes. Twilio REST 4xx/5xx →
phone.twilio.call_failed.

DISTRIBUTION.md — adds Phase 4.10 (distribution test, 4 tracks: cloud
deploy + signed DMG + fresh-Mac validation + telemetry emission) and
Phase 4.11 (admin panel summary). Track 4 marked DONE.

Verified: stando typecheck clean, 150/150 tests pass, swiftc parse
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud control plane (auth, dashboard, /api/*) now lives at
sutando.ag2.ai. Downloads + Sparkle appcast served from ag2.ai/sutando.

Replaced everywhere except env-var override paths (SUTANDO_CLOUD_URL,
SutandoCloudURL Info.plist key, NEXT_PUBLIC_APP_URL) which still take
precedence — the new defaults just match production.

- src/Sutando/CloudAuth.swift — apiBase fallback → sutando.ag2.ai
- src/Sutando/SparkleUpdater.swift — feedURL → ag2.ai/sutando/updates/<channel>/appcast.xml
- app/Info.plist — SUFeedURL → ag2.ai/sutando/updates/internal/appcast.xml
- app/sparkle/generate-appcast.sh — log message
- src/dashboard.py — Website link in HTML footer
- README.md — Website badge → ag2.ai/sutando
- DISTRIBUTION.md — every architectural reference

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Partnership with Tigris. S3-compatible so the implementation when
transcript storage actually ships will be identical
(@aws-sdk/client-s3 with AWS_ENDPOINT_URL_S3 pointed at Tigris).

Both have zero egress; Tigris adds versioning + object lock + dynamic
data placement that R2 lacks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated via app/sparkle/generate-keys.sh. Private key is in macOS
keychain (item: "Private key for signing Sparkle updates"); also
exported + base64-encoded for the SPARKLE_PRIVATE_KEY_BASE64 GitHub
Actions secret so release.yml can sign appcast entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sparkle 2's SPUUpdater doesn't expose `delegate` as a settable property
the way SUUpdater did in Sparkle 1; the delegate must be passed at
controller construction. Defer controller creation to after super.init
so `self` is available as updaterDelegate.

Surfaced by the v0.2.0 CI release build:
  src/Sutando/SparkleUpdater.swift:37:33: error: value of type
  'SPUUpdater' has no member 'delegate'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.2.0 notarization came back Invalid because:

1. Bundled node + tmux + dylibs in Contents/Resources/runtime/ were
   ad-hoc signed by bundle-runtime.sh and never re-signed with the
   Developer ID. Apple's notary walks every Mach-O in the bundle and
   rejects anything not signed by your team.

2. Sparkle.framework helpers were re-signed with --timestamp=none.
   Notarization REQUIRES a secure Apple TSA timestamp on every signature.

3. notarize.sh failed silently on Invalid status — the CI log showed
   only "status: Invalid" with no diagnostic detail.

Fixes:

- build-app.sh re-signs every Mach-O under Resources/runtime/ with the
  Developer ID + hardened runtime + secure timestamp + a runtime
  entitlements file (allow-jit + allow-unsigned-executable-memory +
  disable-library-validation + allow-dyld-environment-variables) since
  V8 JIT and child processes don't inherit the parent's entitlements.
- Drops --timestamp=none from Sparkle helper signing; switches to
  default Apple TSA timestamp when SIGNING_IDENTITY is real.
- New app/Sutando-runtime.entitlements for the bundled binaries.
- notarize.sh now captures the submission ID, detects Invalid status,
  and runs `xcrun notarytool log` to print the JSON diagnostic — so
  next-time failures self-explain in the workflow log.

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

v0.2.0 round 2 still rejected — npm packages ship pre-built native
binaries (ripgrep, fsevents, sharp, esbuild, libvips, .node addons)
and skills/ ships a custom rec-indicator helper. Apple's notary walks
every Mach-O in the bundle, not just our /runtime/ subdir.

Widened the recursive sign pass from Resources/runtime/ to all of
Contents/Resources/, and added .node and dylib name patterns to the
find. Same runtime entitlements (allow-jit + library-validation off)
applied to all — needed because node addons load alongside
non-Apple-signed dylibs.

Apple's complaint shape (from the v0.2.0 round-2 notarytool log,
visible now thanks to the previous commit's log-on-failure):
  "The binary is not signed with a valid Developer ID certificate"
  "The signature does not include a secure timestamp"
  "The executable does not have the hardened runtime enabled"

These hit ~10 npm-shipped binaries. The recursive sign pass solves
all three for every Mach-O found.

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

Two changes in one commit since both block end-user delivery:

1. Notarize round 3 fix — extend recursive Mach-O sign to Frameworks/

   The previous round caught all node_modules + runtime binaries but
   missed Sparkle.framework's nested helpers. Apple specifically
   complained about:
     Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater

   The find walk now covers both Resources/ AND Frameworks/, so every
   Mach-O — including Updater.app's inner binary, the XPC service
   binaries, Autoupdate, and the framework's own dylib — gets re-signed
   with our Developer ID + hardened runtime + secure timestamp + runtime
   entitlements before the outer Sparkle helpers + .app are sealed.

2. Auto-update wiring — Sparkle feed → GitHub Releases

   Previously SUFeedURL pointed at ag2.ai/sutando/updates/<channel>/appcast.xml
   which doesn't exist yet. Repointed to:
     github.com/AG2Platform/stando/releases/latest/download/appcast.xml

   GitHub redirects /releases/latest to the most recent non-prerelease.
   release.yml previously marked all non-stable channels as prerelease
   (so internal releases never appeared as "latest" → Sparkle 404). Now
   only `beta` is marked prerelease; `internal` is treated as latest
   until stable channel exists. Multi-channel filtering still works via
   <sparkle:channel> tags inside the appcast (Sparkle's built-in
   mechanism, controlled by SUSelectedChannel UserDefault).

   When ag2.ai's CDN is set up, swap the URL back in one commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.2.0 round 4 hit the 60-min job cap with submission still "In
Progress" at minute 58. Build + sign takes ~2 min; the rest was Apple
queue, which can stretch past an hour during peak/post-resubmit-storm
windows. 120 gives enough headroom that we don't waste a queue slot
on retries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple's notary scan time scales with the number of Mach-O binaries in
the bundle. After 4 hours of "In Progress" status on the v0.2.0
notarization with no response, traced the slowness to 21 native binaries
in node_modules — 5 of which come from packages with zero callers.

Removed @anthropic-ai/claude-agent-sdk (68M, includes ripgrep + sharp +
libvips natives) — DISTRIBUTION.md flagged it as unused months ago, but
it lingered in the bundle because bodhi-realtime-agent declares it in
optionalDependencies and npm pulls those by default. No code imports it.

Also stripping @img/* sharp natives (15M, transitive of claude-agent-sdk
but standalone-installed too).

Switched the bundle staging from rsync-the-dev-tree to `npm ci --omit=dev`
directly into Resources/repo/. That cuts another ~30M (typescript +
@types/* + their transitives) which only matter at typecheck time.

Bundle reduction:
  node_modules size:    192M → 108M  (44% smaller)
  Mach-O bins to sign:   21  → 16    (24% fewer)

Side cleanups in package.json:
- @types/ws moved to devDependencies (where it belongs — types only)
- tsx moved from devDependencies to dependencies (the launchd plists
  invoke `npx tsx` at runtime)
- Removed redundant top-level @anthropic-ai/claude-agent-sdk pin

Also fixes a TS compile break that surfaced when npm install pulled
TypeScript 6.0.2 → 6.0.3 (stricter `private` enforcement on
VoiceSession.clientConnected). Added a typed-bypass helper instead of
spreading `as any` casts at the 5 callsites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple's notary routes apps with weaker memory protections (especially
allow-unsigned-executable-memory on the main executable) into an
"in-depth analysis" path that stretches from minutes to days
(forum thread 707377). That's exactly what stuck v0.2.0 in queue for
12+ hours. The Swift launcher doesn't JIT — it never needed the
entitlement. Bundled node + tmux still get it via
Sutando-runtime.entitlements where they actually need it.

Also:

- Split Mach-O re-sign into two passes: Resources/ uses
  Sutando-runtime.entitlements (allow-jit etc); Frameworks/ uses
  --preserve-metadata=entitlements on Sparkle's Downloader.xpc so we
  don't clobber its sandbox entitlements (would break Sparkle's
  privilege-separated download at runtime).
- Sparkle helpers signed deepest-first per Sparkle's codesigning guide,
  with the framework wrapper sealing them last.
- notarize.sh no longer blocks on `xcrun notarytool submit --wait`.
  Polls every 30s for up to NOTARY_MAX_WAIT_SEC (default 1800s = 30m),
  then exits non-zero with submission ID preserved so a follow-up can
  resume via `notarytool info <id>` + `stapler staple` if Apple
  eventually decides hours later.
- release.yml: timeout 120 → 75 min (notarize.sh self-bounds now).

Verified: 4 prior submissions of this build (with the old entitlement)
took 12+ hours each in Apple's queue. With the entitlement removed,
notarization should follow the fast path (~5–15 min normal SLA).

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

Pin bodhi-realtime-agent to sonichi/bodhi_realtime_agent#ffec0cd which
sends `audio:` instead of `media:` in Gemini's sendRealtimeInput call.
The npm-published 0.1.5 we were on shipped a build that pre-dates
this fix and still uses `media:`, which the @google/genai SDK maps to
the deprecated `realtime_input.media_chunks` wire format. Gemini 3.1
recently flipped the deprecation from soft warning to hard reject
(WebSocket close 1007), breaking voice on every install.

The fork SHA is the same as the original Phase 0 pin — unchanged
upstream, but the SOURCE there has the fix even though no new tarball
was published. Installing via npm's git source forces a build from
that source, which produces the corrected dist/.

Verified locally: `dist/chunk-*.js` now contains
  this.session.sendRealtimeInput({ audio: { data: base64Data, ... } })
not `media: { ... }`. Typecheck + 150 tests pass.

Phase 0's "won't survive notarization" concern doesn't apply to git
sources of npm packages — the bundle ends up with regular built JS in
node_modules, identical structure to npm-installed packages. Apple's
notary doesn't care about resolution origin.

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

Two bugs prevented v0.2.1 from being offered as an upgrade to a v0.2.0
client running "Check for Updates…":

1. Workflow bug — generate-appcast ran BEFORE the version-rename step,
   so Sparkle embedded the bare filename `Sutando.dmg` into the
   enclosure URL. The actual GitHub asset is `Sutando-0.2.1.dmg`,
   meaning even if the appcast surfaced, the DMG download would 404.
   Reordered: rename → generate-appcast → rewrite URLs to point at
   the canonical `releases/download/v<ver>/Sutando-<ver>.dmg`. Stable
   per-tag URL — no `releases/latest` redirect magic that breaks once
   we ship a stable channel.

2. Sparkle bug — every appcast item we ship is tagged
   <sparkle:channel>internal</sparkle:channel>. Sparkle filters tagged
   items unless the user's SUSelectedChannel matches. Fresh installs
   have no SUSelectedChannel default, so Sparkle silently skipped every
   item ("you're up to date" lie). Register the default at app launch.
   Users can still override via `defaults write`.

Test the existing v0.2.1 release with the bug-fixed expectation: client
must have `defaults write com.sutando.app SUSelectedChannel internal`
manually applied since the v0.2.0 binary didn't register it. The next
release built off this commit will register on launch automatically.

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

Two related changes that surfaced from a real teardown of the v0.2.0
install on a clean account:

1. tmux leak — `LaunchAgentInstaller.uninstall()` unloaded the launchd
   jobs but left the `sutando-core` tmux session running on
   `/tmp/sutando-tmux.sock`. tmux daemonizes out of the wrapper's
   process tree, so killing the launchd job doesn't reach the server.
   Added `killCoreAgentTmux()` that resolves tmux from the bundled
   runtime → Homebrew → /usr/bin and runs `kill-server` + removes the
   stale socket. Fixes both the menu-bar "Uninstall Background
   Services" and the new in-app uninstall.

2. New "Danger zone" → Uninstall Sutando… in Settings. NSAlert with a
   "Keep my settings and API keys" checkbox (default on, so reinstalls
   retain the user's `.env` + results). New `Uninstaller.swift` runs
   the teardown on a background queue: services + tmux, cloud sign-out
   (keychain + cloud-auth.json), skill symlinks pointing into our
   bundle (leaves user's own skills alone), Library caches/HTTPStorages/
   WebKit/Saved Application State, optionally Application Support,
   `tccutil reset All com.sutando.app`. Spawns a detached
   /tmp/sutando-uninstall-<pid>-<rand>.sh that waits for our PID to
   exit, then removes the .app bundle and the Preferences plist (which
   the running app rewrites on quit), then deletes itself. Bundle
   removal is skipped when not launched from a .app, so dev users
   running the raw binary don't lose their checkout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4.10 Track 1 step 7 — auth-gated DMG download endpoint that
streams the latest GitHub release through Vercel. Records the
GITHUB_TOKEN + GITHUB_RELEASE_REPO env requirements and the new
download_events table.

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

The v0.2.1 SUSelectedChannel-registration fix was a dud: Sparkle 2.9.x's
binary has zero references to that user default. Channel filtering goes
through `SPUUpdaterDelegate.allowedChannelsForUpdater:` exclusively,
which we never implemented. Sparkle was treating allowed-channels as
empty, so every item tagged <sparkle:channel>internal</sparkle:channel>
was silently filtered out and "Check for Updates" reported "you're up
to date" no matter how new the release was.

Confirmed via:
  $ strings .../Sparkle.framework/Sparkle | grep -i SUSelectedChannel
  (no matches)
  $ strings .../Sparkle.framework/Sparkle | grep allowedChannelsFor
  allowedChannelsForUpdater:
  Error: -allowedChannelsForUpdater: cannot return nil. Treating this
  as an empty set.

Implementation reads SUSelectedChannel from user defaults if set
(comma-separated allowed for QA: `defaults write com.sutando.app
SUSelectedChannel internal,beta`), otherwise defaults to {"internal"}
which is the channel every release we ship today is tagged with.

Note for the v0.2.1 → v0.2.3 cliff: v0.2.1 clients still have the
broken Sparkle build and cannot pull v0.2.3 in via auto-update. A
manual DMG swap is needed once; subsequent v0.2.3 → v0.2.4+ updates
will work normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cancelTaskTool resolved tasks/ + results/ via process.cwd(), which
inside the .app bundle landed at /Applications/Sutando.app/Contents/
Resources/repo/tasks/ — read-only and non-existent — so listing or
cancelling tasks errored with ENOENT on a fresh install. Switch to
stateDir() so it lands in $SUTANDO_HOME (the user's writable state
dir), and gets auto-created on first use.

Surfaced via the voice agent on a clean second-Mac install.

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

The Settings → Install Claude Code button runs `curl ... claude.ai/install.sh
| bash`, which lands the binary at ~/.local/bin/claude. The Settings UI
detects it (claudeCodePath checks ~/.local/bin) but the wrapper's PATH
extension didn't include it, so `command -v claude` failed under launchd
and the wrapper crash-looped before ever creating the tmux socket — the
sutando-core session never came up for users without Homebrew claude.

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

The plist sets WorkingDirectory=SUTANDO_HOME, so claude's cwd is
~/Library/Application Support/Sutando — but proactive-loop SKILL.md uses
relative paths like `bash src/watch-tasks-stream.sh` and `python3
src/health-check.py`, which only work when cwd contains a `src/` tree.
On a developer's box this had been masked by the LLM learning to prepend
`cd ~/stando &&` to script invocations from accumulated session memory;
on a fresh non-dev install (no ~/stando clone), the cd fails silently
and every relative `src/`- or `skills/`-rooted command in the loop dies.
Diagnosed by attaching to the .app's launchd-spawned tmux: claude was
running but `pgrep -f watch-tasks` returned empty.

Wrapper now symlinks src, skills, node_modules, package.json,
package-lock.json, tsconfig.json, CLAUDE.md from the bundled repo into
SUTANDO_HOME on every start. Idempotent — skips when a real file/dir
already exists at the path so we never clobber user content. Replaces
dangling symlinks so .app moves (Sparkle relocation, drag to a
non-/Applications path) self-heal on next launchd restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dashboard.py and health-check.py had a hardcoded pgrep pattern of
`src/Sutando/Sutando` left from the dev workflow. The .app's binary
lives at `/Applications/Sutando.app/Contents/MacOS/Sutando`, so under
the distributed install the menu bar app is reported as down even when
it's running — the user just sees "✗ Sutando app not running" on the
dashboard while the app is alive.

Pattern broadened to `(Sutando|MacOS)/Sutando` (already used in
health-check.py for the pgrep call itself; this just removes the dev-
only existence gate and the stale literal in mark_stale_if_outdated).
Dev binary staleness check kept gated on dev_bin existence — bundled
.app and its source share a build mtime so the comparison there is
always equal.

Other launchd-spawned services (agent-api, voice-agent, web-client,
credential-proxy, screen-capture) verified clean: their pgrep patterns
are service-name-based or already-broadened, and state IO routes
through util_paths state_dir/state_path so cwd-as-REPO_DIR is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Code 2.1.x shows two interactive dialogs on first launch:
  1. Workspace trust ("Is this a project you trust?", default cursor on
     "Yes, I trust this folder" — bare Enter accepts).
  2. Bypass-permissions warning ("Yes, I accept", default cursor on
     "No, exit" — Down+Enter accepts).

On an already-trusted Mac only #2 appears, which is what the previous
blind `sleep 2; send-keys Down Enter` was sized for. On a fresh Mac the
trust dialog renders first; Down on that dialog selects "No, exit",
Enter then quits claude, and launchd crash-loops the session
indefinitely. Watcher never comes up, tasks pile up unread.

Replace the single shot with a capture-pane-driven loop: identify which
dialog is visible at each tick and send the right keystroke. Exit once
two consecutive checks are dialog-free. Covers fresh-Mac (trust then
bypass), relaunch (bypass only), and the "TUI slow to render" case
the prior fixed-2s-sleep raced.

Diagnosed on a teammate's second Mac that had v0.2.5 installed but
core-agent stuck on "1. No, exit" of the bypass dialog because the
keystrokes had been spent on the trust dialog earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Code 2.1.133's TUI reads stdin fast enough that `send-keys Down
Enter` in a single tmux call confirms the default-highlighted "1. No,
exit" before the cursor visually advances. Wrapper exits, launchd
crash-loops every 30s, voice tasks never reach core. Surfaced on a
fresh DMG install on a new Mac.

Split into two `send-keys` calls with a 0.3s gap. Verified empirically:
the cursor lands on "Yes, I accept" and claude proceeds to /proactive-loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SirEntropy and others added 26 commits May 19, 2026 16:33
app/build-app.sh runs `pnpm --filter @sutando/client build` to produce
client/dist/. The release runner only had npm, so the v0.3.1 build
exited 127 at that line. Add pnpm/action-setup before the existing
Node setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.3.1 release CI failed on `npm run typecheck` with TS2591 (cannot find
name 'process') and TS2339 (no property 'meta' on `import`) in
`client/vite.config.ts`. The file uses `node:url` + `import.meta.url`,
but the `client/` workspace didn't declare `@types/node` and
`tsconfig.node.json` didn't include node in its `types` array. Worked
locally because the root's `@types/node` is visible when client has no
isolated `node_modules`; failed under CI's pnpm-isolated install.

The lockfile delta also picks up a pre-existing relock drift — root
`package.json` already specifies `patch-package@^8.0.1` but the
committed lockfile didn't have it. Folding into one PR per Rick's call;
separating out would require constructing a fragile drift-only
predecessor PR.

Failed run: https://github.com/AG2Platform/stando/actions/runs/26131742742

Co-Authored-By: mini <mini@SirEntropy.local>
mini: declare @types/node in client for CI typecheck
build-app.sh runs `pnpm --filter @sutando/client build` (tsc -b && vite
build), which needs client/node_modules. The workflow only ran `npm ci`
at the root, and root package.json has no npm `workspaces` field, so
client/node_modules was empty — vite.config.ts failed to resolve
@vitejs/plugin-react and @tailwindcss/vite (TS2307).

Add an explicit `pnpm install --frozen-lockfile` between Node setup and
the .app build. Safe because the bundle later runs its own `npm ci
--omit=dev` inside Resources/repo/ — it doesn't reuse the root
node_modules layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`BrandMark.tsx` (added in 27a9152 "cleanup") imports
`@/assets/sutando-logo.png`, but the PNG was caught by the broad
`assets/` rule in .gitignore and never landed in the repo. Vite build
failed with ENOENT during `pnpm --filter @sutando/client build`, which
is the third blocker in the v0.3.1 release run.

Copy `app/branding/menubar-source.png` to the expected client path
(per BrandMark.tsx's own docstring naming it the canonical source)
and carve out `client/src/assets/` from the global `assets/` ignore.

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

Replace the fork's SUTANDO_HOME runtime-state model with upstream's
SUTANDO_WORKSPACE / resolve_workspace() (default ~/.sutando/workspace/).

- Add upstream path layer verbatim: workspace_default.{py,ts},
  util_paths.{py,ts}, docs/workspace-design.md
- Add state_paths.py / state-paths.ts — thin resolve_workspace() wrappers
  so util_paths.* stay byte-identical to upstream
- Sweep SUTANDO_HOME -> SUTANDO_WORKSPACE across 8 Python, 10 TypeScript,
  5 shell, 2 .mjs files

typecheck passes. Tests are RED pending CP-0e (test-suite refactor to a
temp SUTANDO_WORKSPACE) — the failures confirm runtime paths moved as
intended. Swift .app layer + LaunchAgent plists still to do.

Part of the upstream merge cherry-pick checkpoint plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tests assumed repo-relative tasks/ + core-status.json; with runtime state
now under $SUTANDO_WORKSPACE the suite needed updating:
- task-bridge-{format,isvoice}: adopt upstream's resolveWorkspace()-based
  TASK_DIR (byte-identical to upstream)
- agent-state-endpoint: resolve core-status.json / voice-state.json via
  resolveWorkspace()
- init-script: runInit() passes SUTANDO_WORKSPACE so init.sh writes into
  the scratch dir the assertions check

Also fixes 2 sweep misses the tests surfaced:
- web-server.ts readVoiceState() read voice-state.json via import.meta.url
  (repo-relative) -> statePath()
- inline-tools.ts showViewTool wrote dynamic-content.json to process.cwd()
  -> statePath()

npm test: 162/162 green. typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Qingyun reported "Failed to send: Load failed. Is the agent bridge
running?" in the conversation pane. WebKit's Load failed means fetch
to http://127.0.0.1:7843/task never reached the bridge. Diagnosis
ruled out ATS, Python version, and Swift-side pkill. The agent-api
process was either dead persistently (vestigial gethostbyname crash
on a hostname-without-A-record, or EADDRINUSE crashloop from a stale
dev-shell bridge) or wedged on a slow shell-out in a sibling handler
(single-threaded HTTPServer + unbounded pgrep/git-log).

The fix closes the symptom class across all four ranked causes
without needing to pin which one fired:

* Drop the unused gethostbyname call between bind and serve_forever;
  it crashes on hostname misconfigurations and the return value is
  discarded.
* Add a connect_ex() preflight before bind so EADDRINUSE produces a
  clear stderr line ("port already in use — try lsof") instead of a
  silent launchd crashloop.
* Switch HTTPServer to ThreadingHTTPServer; consolidate the two
  unbounded pgrep calls into _pgrep_ok() with timeout=2 and graceful
  degradation. Removes the head-of-line wedge class.
* Wrap serve_forever in a broad except so launchd-respawn cycles
  leave diagnostic tracebacks in agent-api.log.
* Add pingAgentApi(); on a network error postWebTask retries once at
  500ms (covers the launchd bootout/bootstrap drain race) then probes
  /ping to classify bridge-down vs task-error and annotates the
  failure shape. useTextSubmit branches the toast accordingly so the
  user knows whether to relaunch or to read the log.

The user-visible failure path stays reversible: the legacy toast
text remains as the default branch for callers that don't return a
kind. Health-aware UI pill, background refresher for the shell-out
endpoints, and a Restart-bridge button are deliberately out of scope
— follow-up PRs.

Diagnosis artifact: ~/.mini/projects/work/sutando/artifacts/
feedback-review-2026-05-20/fix-agent-api-unwedge-pr.md

Co-Authored-By: mini <mini@SirEntropy.local>
mini: unwedge agent-api bridge + classify client send failures
Mark filed two feedback rows on 2026-05-20 with the same root cause —
desktop-dashboard rows reporting a service as down/stale while the
underlying process was healthy:

  10fcce74-… "Discord-Bridge says stale heartbeat but working fine"
  04d622a4-… "Says brain offline when it is not"

Both bugs are writer/reader path mismatches that survived the Slice-4
personal-paths migration. The migration moved per-machine runtime state
under $SUTANDO_HOME (so the .app bundle stays read-only after install)
and added util_paths.state_path() as the canonical helper. Most callers
were updated; two were missed.

* src/discord-bridge.py:2643 wrote the heartbeat to REPO/state/, but
  src/health-check.py:759 read it from state_path(...) which resolves
  to $SUTANDO_HOME/state/. When $SUTANDO_HOME != REPO_DIR (the default
  in the .app), the writer ticked every 60s but the reader saw a
  phantom-empty path. Drops through to the log-stale branch and
  reports "running but heartbeat stale (148s old)" — verbatim what
  the screenshot shows.

* src/health-check.py:551 read core-status.json from
  REPO_DIR/core-status.json, but src/agent-api.py:279 (the writer)
  and src/web-server.ts:194 (the other reader) both use
  state_path("core-status.json"). When $SUTANDO_HOME differs,
  check_core_proactive_loop() finds no file and returns "not yet
  written" — which the desktop dashboard renders as "brain offline".

src/telegram-bridge.py:132,323 was already correct (STATE_DIR =
state_dir("state"); STATE_DIR / "telegram-bridge.heartbeat"). The two
broken sites now match that idiom.

PR #11 (closed 2026-05-19) addressed the discord-bridge symptom by
patching the running .app bundle. The closer-comment claimed the
canonical discord-bridge.py already used resolve_workspace correctly,
but line 2643 on main was still hardcoded to REPO. The proper rebuild
lands here.

Test coverage:

* New tests/health-check-stuck-loop-home-overrides.test.py exercises
  the SUTANDO_HOME != REPO_DIR codepath that the original
  health-check-stuck-loop.test.py never reached (it set REPO_DIR
  directly via monkey-patch and never touched SUTANDO_HOME).

* The original health-check-stuck-loop.test.py is updated to set
  SUTANDO_HOME alongside its REPO_DIR override so its helpers
  exercise the new path resolution. All 17 existing cases still pass.

Out of scope, deliberately held: the desktop dashboard
"7/7 SERVICES OK" tile vs the per-row "stale" label — a dual-truth
bug in the UI renderer, separate from the heartbeat read.

Diagnosis artifact:
~/.mini/projects/work/sutando/artifacts/feedback-review-2026-05-20/
fix-health-check-paths-pr.md

Co-Authored-By: mini <mini@SirEntropy.local>
mini: heartbeat + core-status paths honor SUTANDO_HOME
… memory-dir rename

Completes the workspace-model adoption (CP-0):
- Swift resolvers (9 spots: main, CloudAuth, CloudClient, Onboarding,
  Feedback, Settings, SkillsViewController, LaunchAgentInstaller,
  ScreenCaptureSupervisor) — SUTANDO_HOME -> SUTANDO_WORKSPACE, default
  ~/.sutando/workspace/. Drops the repo-root dev fallback (the workspace
  is never the repo, per docs/workspace-design.md).
- 10 LaunchAgent plist templates: {{SUTANDO_HOME}} -> {{SUTANDO_WORKSPACE}}
- SUTANDO_PRIVATE_DIR -> SUTANDO_MEMORY_DIR (legacy name honored as a
  fallback) at the 5 direct-read sites.

Swift compiles clean (swiftc -typecheck, exit 0). typecheck + npm test
162/162 green. Full .app rebuild (app/build-app.sh) deferred to a
controlled run since it reinstalls LaunchAgents + restarts services.

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

Adopt three new shared modules from upstream verbatim (byte-identical):
- result_markers.py  — unified result-body marker parser (sonichi#874)
- task_priority.py   — three-tier task priority + per-source defaults (sonichi#765)
- core_heartbeat.py  — per-host liveness signal (sonichi#767)

Plus their unit tests (result-markers/task-priority/core-heartbeat .test.py).
Purely additive — consumers wired in later checkpoints (CP-2 health-check
uses core_heartbeat; CP-3 bridges use result_markers). npm test 162/162 +
all 3 new Python suites green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopt upstream's health-check.py — it carries the live-core gate, TCC
Documents-access detection, absolute-path pgrep hardening, and the
tunnel-check skip, and resolves paths via resolve_workspace() (the fork's
canonical resolver post-CP-0). Also adopt upstream's two health-check
test files.

Then re-apply two fork-architecture specifics:
- Sutando-app detection also recognises the installed
  /Applications/Sutando.app (upstream only checked the dev binary);
  pgrep pattern broadened to (Sutando|MacOS)/Sutando; source-vs-binary
  staleness gated to dev builds.
- web-client -> voice-agent-http / web-server.ts: PR-A folded the HTTP
  server into voice-agent, so there is no standalone web-client process
  or com.sutando.web-client plist.

Brings in the sonichi#825 live-core gate (_any_core_alive, backed by CP-1's
core_heartbeat module) — without it the merge would regress the
stuck-loop / duplicate-task bug.

npm test 162/162 green. health-check.py runs clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopt upstream's bridge versions — TOFU auto-onboard (sonichi#808), forward-from
attribution (sonichi#905), owner-only [channel:] redirect (sonichi#737), owner-DM-edits
(sonichi#824), tierMap DM ordering (sonichi#846), unified marker parsing via
result_markers (sonichi#874), vision_push image helper, task_priority headers,
workspace-path resolution — and re-apply the fork's cloud-metering layer:

- _emit_channel_metric(in/out): one usage event per accepted message and
  per sent reply
- _cap_hit_reply_text + per-channel/chat cap-hit reply throttle (~5 min)
- cap-hit short-circuit that bounces a message when the tier is exhausted
  (owner tier exempt)

discord: converted the metering cloud_metrics import to the guarded form
(matching telegram) — upstream's self-rescue re-exec shows the bridge runs
in fragile interpreter environments where an unguarded import would crash
the whole bridge.

npm test 162/162 green.

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

installPythonSkillDeps() hardcoded /usr/bin/python3, but the service
plists run {{PYTHON_BIN}} — the bundled-runtime python when present,
else /opt/homebrew/bin/python3. discord.py landed in the wrong
interpreter's --user site, so discord-bridge (launched via the plist
under /opt/homebrew/bin/python3) crash-looped on `import discord`.

Pass the resolved pythonBin from placeholders() into
installPythonSkillDeps() so deps install where the services look.

swiftc -typecheck clean. Takes effect on the next app/build-app.sh +
install; the current install was fixed by a manual pip install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves PR #17's conflict with main's PRs #14 (agent-api unwedge) and
#15 (heartbeat + core-status paths honor SUTANDO_HOME).

Conflicts — 3 hunks, all health-check. PR #15 fixed core-status path
resolution on the old SUTANDO_HOME model; this branch's CP-2 already
resolves it via the adopted SUTANDO_WORKSPACE model. Kept the branch's
version — functionally equivalent (#15's intent preserved), no behavior
lost. main's non-conflicting commits auto-merged in.

Also fixed two issues the merge surfaced:
- discord-bridge.py: main's auto-merged state_path("state/discord-bridge
  .heartbeat") referenced an import this branch's upstream-based version
  dropped — switched to STATE_DIR / "discord-bridge.heartbeat".
- CP-3 metering bug: len(_chunk_for_discord(...)) — upstream's
  _chunk_for_discord is a generator (the fork's was a list); wrapped in
  list().

Removed tests/health-check-stuck-loop-home-overrides.test.py — main #15
added it to assert the SUTANDO_HOME path model, which CP-0 deliberately
retired. The override coverage it provided is kept by
health-check-stuck-loop.test.py (patches WORKSPACE_DIR).

npm test 165/165 + test:py green. typecheck clean.

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

agent-state-endpoint.test.ts's before() hook writes core-status.json /
voice-state.json into resolveWorkspace() (~/.sutando/workspace/), but
resolveWorkspace() does not create the directory. On a dev machine the
dir already exists so the test passed locally; on a clean CI runner it
does not — writeFileSync threw ENOENT, the before() hook failed, and all
9 subtests were cancelledByParent.

mkdirSync(resolveWorkspace(), { recursive: true }) at the top of before().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
merge: upstream sync CP-0–CP-3 — workspace model, core modules, bridges
Adopt upstream's Slack bridge (Socket Mode DMs + @mentions + file
attachments) and the cross-bridge outbox audit log it writes to.

- src/slack-bridge.py — new bridge; TOFU onboarding, tierMap access
  tiers, allowlist-gated file sends, result-marker parsing
- src/outbox_log.py — visibility log for outbound bridge sends; the
  slack bridge's send path appends to it (import was a no-op without it)
- tests + docs/slack-bridge.md adopted verbatim

Upstream PRs: sonichi#867, sonichi#892, sonichi#901, sonichi#917, sonichi#925, sonichi#931. All imports resolve
to modules already landed in CP-0/CP-1 (workspace_default, task_priority,
result_markers).
The Slack bridge previously read SLACK_BOT_TOKEN / SLACK_APP_TOKEN only
from the ambient environment — there was no way to configure it from the
UI. This adds the full path:

- web-server.ts: GET/POST /settings/slack — POST validates the xoxb-/
  xapp- prefixes and writes ~/.claude/channels/slack/.env mode 0600
  (same credential convention as the Discord/Telegram bridges); GET
  reports only whether each token is set, never the values.
- slack-bridge.py: load that .env on startup (is_file guard so a stray
  path can't crash module load). Ambient env still wins.
- client: SettingsPage gains an Integrations section with a Slack card
  (SlackTokenForm molecule + useSlackSettings hook + api.ts wrappers).
  Token inputs are password fields; the card shows a configured /
  not-configured chip per token.

Tokens take effect on the next Slack bridge restart.
The macOS app's Settings tab is native Swift (SettingsWindow.swift), not
the React web page — so the earlier web Settings card never showed there.
This adds Slack to the native Channels section.

Slack needs two tokens (xoxb- bot + xapp- app-level), but ChannelConfig
modelled one envKey per channel. Generalized it:
- ChannelConfig.envKey → fields: [ChannelField], each with its own
  env key, label, and placeholder. Discord/Telegram keep one field;
  Slack has two.
- channelRow renders one secure field per ChannelField; editors are
  keyed "<channelId>/<envKey>". save/load iterate the fields.
- New com.sutando.slack-bridge.plist.template — PathState-gated on
  ~/.claude/channels/slack/.env, same lifecycle as discord/telegram.
- slack_bolt added to LaunchAgentInstaller's auto-installed pip deps.

Tokens still land in ~/.claude/channels/slack/.env, which slack-bridge.py
reads (b181147). Web Settings card and native card write the same file.
…he row

Slack's help text is much longer than Discord's/Telegram's. The hint
NSTextField had no width constraint, so its intrinsic width was the full
single-line string — widening the Slack row past the 504px editors and
pushing the whole block out of left-alignment with the other channels.
Constrain the hint to 504 (preferredMaxLayoutWidth + width anchor) so it
wraps within the editor width and every channel row is the same width.
Adopt upstream's vision capability backend: frame capture/streaming
tools, the vision controller, and a localhost control server; plus
vision_push.py for out-of-process frame pushes (used by bridges).

Port collision fix: upstream defaults the vision control server to
7847, which the fork's terminal-server.ts already owns. Moved the
default to 7848 (VISION_CONTROL_PORT) in both files.

Wiring into inline-tools.ts / voice-agent.ts is deferred to CP-6
(voice-agent reconcile). Upstream PRs: sonichi#697, sonichi#715, sonichi#735.
recent_context, pidfile lock, gws-gmail-voice

Reconciles upstream's voice/agent-capability features into the fork's
voice-agent.ts + inline-tools.ts (both heavily fork-divergent).

voice-agent.ts:
- pidfile single-instance lock (sonichi#738) — duplicate processes exit before
  any side effect instead of stranding the vision control port
- result-watcher inject re-check + 1 retry + Discord stuck-voice
  fallback (sonichi#924)
- `[System: ...]` speech guard added to the agent prompt (sonichi#847)
- vision wiring (sonichi#735) — setVisionSession + startVisionControlServer on
  session start, teardown on shutdown
- clearActiveArtifact() on onSessionEnd (sonichi#828)

inline-tools.ts:
- register vision tools + artifact-cache tools
- recent_context tool (sonichi#665) — reads state/voice-session-context.json
  via the workspace resolver; writer convention documented in CLAUDE.md
- tier-aware skill-manifest loader (sonichi#702) — manifests split owner vs
  any_caller; integrated into the fork's cloud-skills bySkill loader

New: src/artifact-cache-tools.ts, skills/gws-gmail-voice/ (sonichi#702/sonichi#704/
sonichi#705/sonichi#743), tests/active-artifact.test.ts. Vision port collision already
fixed in CP-5.

Deferred: sonichi#669 deictic-bias prompt line — depends on a `read_selection`
tool the fork doesn't have; would instruct Gemini to call a missing
tool. sonichi#791 conversation-store is its own feature (touches task-bridge /
CP-7 scope), not bundled here.
… chat tracking

Reconciles upstream's task-bridge.ts hardening into the fork (which had
diverged with telemetry + stateDir changes).

- work() dedup (sonichi#820): identical normalized task text within a 2-minute
  window returns the existing taskId instead of spawning a duplicate run
  — kills the voice over-delegation cascade.
- voice-only push channel (sonichi#923): results/voice-*.txt files are spoken
  on the next voice turn or held in queue until reconnect; no Discord
  fallback.
- result-watcher containment (sonichi#761): try/catch around the _pendingTasks
  timeout-check loop + a logging catch on the result-scan, so one throw
  can't silently kill the 2s watcher.
- chat-path task tracking (sonichi#695): writeChatTask() helper, task-chat-*
  archival in the result watcher, createChatTaskTool defined (unregistered
  future hook), CLAUDE.md shell-snippet for core-agent self-tracking.
- result-body marker docs (sonichi#689): CLAUDE.md + proactive-loop SKILL.md.

sonichi#791 conversation-store stays a separate checkpoint.
@jsun-m

jsun-m commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

Opened against upstream by mistake (gh defaulted to the fork parent). This belongs on the AG2Platform/stando fork. Closing.

@jsun-m jsun-m closed this May 21, 2026
@CLAassistant

CLAassistant commented May 21, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
2 out of 3 committers have signed the CLA.

✅ SirEntropy
✅ jsun-m
❌ rickchen007
You have signed the CLA already but the status is still pending? Let us recheck it.

@jsun-m
jsun-m deleted the merge/upstream-cp4-7 branch May 21, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants