Repository navigation
Conversation
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>
… signed releases ready
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>
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.
Collaborator
Author
|
Opened against upstream by mistake (gh defaulted to the fork parent). This belongs on the AG2Platform/stando fork. Closing. |
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.outbox_log.pyaudit log.vision-tools.ts+vision_push.py(screen-to-Gemini frame streaming). Control server moved off:7847(collided with terminal-server) to:7848.[System:]speech guard (fix(voice-agent): forbid speaking [System: ...] framing aloud (closes #829) #847), vision wiring into the live session (feat(vision): screen sharing + push-mode vision pipeline #735), in-session artifact cache (feat(voice-agent): in-session artifact cache for fast document Q&A (closes #642) #828),recent_contexttool (feat(voice-session-context): recent_context tool + writer convention #665), tier-aware skill loader +gws-gmail-voiceskill (feat(gws-gmail-voice): manifest skill exposing triage_email inline tool #702/feat(gws-gmail-voice): cache-first 3-tier path + importance scoring #704/refactor(gws-gmail-voice): retire rule-based scorer for LLM judgment #705/feat(gws-gmail-voice): inline read_email + search_email — close §5 Now Gmail gap #743).work()dedup (fix(task-bridge): dedup work() submissions with identical text within 2-minute window #820), voice-only push channel (feat(task-bridge): voice-only push channel via voice-*.txt filename #923), result-watcher containment try/catch (fix(task-bridge): containment try/catch around result-watcher loop body #761), chat-path task tracking (fix(task-bridge): track chat-path tasks in dashboard #695), result-marker docs (docs(task-bridge): document [deduped]/[no-send]/[REPLIED] result-body markers #689).ChannelConfigwas generalized). Newcom.sutando.slack-bridgelaunchd job;slack_boltadded to auto-installed deps.#791(conversation-store SQLite mirror) is intentionally deferred to its own checkpoint. The finalgit merge upstream/mainhappens after all checkpoints land.33 files changed, +3767/−105.
Test plan
npx tsc --noEmitclean (root + client)npm test— 176 pass, 0 failswiftc -parseclean on the changed Swift files.app, confirm voice + conversation screen come up:7848🤖 Generated with Claude Code