Skip to content

fix(memory-sync): resolve memory-index dir from workspace, not __file__ - #1028

Closed
jsun-m wants to merge 150 commits into
sonichi:mainfrom
AG2Platform:fix/memory-index-symlink-workspace-resolution
Closed

jsun-m wants to merge 150 commits into
sonichi:mainfrom
AG2Platform:fix/memory-index-symlink-workspace-resolution

Conversation

@jsun-m

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

Copy link
Copy Markdown
Collaborator

The bug

skills/cross-node-sync/scripts/regenerate-memory-index.py → default_memory_dir() derived the Claude Code project slug from Path(__file__).resolve().parent walked up four levels.

When skills/ is an app-bundle symlink (the normal install layout), .resolve() follows it into /Applications/Sutando.app/Contents/Resources/repo. The slug then pointed at an empty memory dir → collect_entries() found 0 frontmatter files → the clobber guard made the regen a silent no-op on every cross-node-sync cron pass (every 7 min).

This is the exact Path(__file__).resolve() anti-pattern CLAUDE.md and workspace_default.py already document.

Found during a proactive-loop pass when the cron-driven regen refused to run:

warning: 0 frontmatter entries but MEMORY.md has 1 content lines — refusing to clobber.

The 4 memory files all had valid frontmatter — the script was just looking in the wrong directory.

The fix

  • Derive the slug from resolve_workspace() (the canonical workspace contract: $SUTANDO_WORKSPACE / ~/.sutando/workspace), never from __file__.
  • _slugify_path() now replaces every non-alphanumeric char. The old code replaced only /, leaving the dot in .sutando — producing -Users-john-.sutando-workspace instead of the real -Users-john--sutando-workspace.
  • $SUTANDO_MEMORY_DIR env override is unchanged (still priority 1).
  • resolve_workspace(migrate=False) — read-only regen, no migration side effects.

Tests

tests/regenerate-memory-index-workspace-resolution.test.py — 3 cases: slugify (dot+slash), env-var precedence, workspace-not-__file__ resolution incl. an explicit assert that the result never lands in Sutando.app.

  • 3/3 pass
  • python3 -m py_compile clean
  • Verified: default resolution (no env, no --memory-dir) now writes 4 entries instead of hitting the clobber guard.

🤖 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>
jsun-m and others added 26 commits May 20, 2026 17:33
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.
…chi#791)

Adopts upstream's sonichi#603 conversation-store — a time-indexed, searchable
SQLite mirror written best-effort alongside the text conversation.log
(text log stays primary truth; sqlite errors are swallowed).

- src/conversation-store.ts (new) — node:sqlite DB helper: conversation,
  sessions, tool_calls, session_events tables; recordConversation /
  recordSessionBoundary / recordSession.
- task-bridge.ts — logConversation/logSessionBoundary parallel-write the
  mirror; logConversation gains an optional sessionId.
- voice-agent.ts — recordSession replaces the data/voice-metrics.jsonl
  writer; per-turn logConversation passes SESSION_ID.
- conversation-server.ts (phone) — recordSession replaces the
  call-metrics.jsonl writer; per-call turns mirror via recordConversation.
- diagnose.py — reads the sessions table (call-metrics.jsonl no longer
  fresh); adopted verbatim (fork was identical pre-sonichi#791).
- import-conversation-log.py / import-session-metrics.py — one-shot
  backfill importers. query-conversation.sh — sqlite3 CLI.
- engines.node >=22.5.0 (node:sqlite floor). Retired two stale
  voice-metrics POC scripts.

Deferred: the sonichi#791 cross-node-sync doc reframes (land with CP-9) and the
init-script.test.ts path fix (belongs with sonichi#913 / CP-11).
node:sqlite is a flagged-experimental builtin until Node 22.13. The
bundled .app runtime is 22.11 — a static `import` of node:sqlite threw
ERR_UNKNOWN_BUILTIN_MODULE at module load, crash-looping the voice-agent
(and taking down the conversation screen) because task-bridge.ts and
voice-agent.ts statically import conversation-store.

Switch to a type-only import + a guarded createRequire('node:sqlite')
inside init(). On a runtime without node:sqlite the mirror disables
itself (initFailed latch, no-op) — the text conversation.log and the
rest of the agent are unaffected. This is what the module's best-effort
contract always intended; a hard static import of an experimental
builtin violated it.
…ne" false alarm

/tasks/active reported claude_ok via `pgrep -f "claude.*sutando-core"`,
but the core agent is launched as `claude … -- /proactive-loop` inside a
tmux session *named* sutando-core (src/run-core-agent.sh). The session
name never appears in the claude process args, so the pattern matched
nothing and the dashboard always showed "brain offline" even with a
healthy core agent.

Match `claude.*proactive-loop` — the command the core agent actually
runs with.
screen-companion, zoom, discord-voice

Adopts six upstream skills. Four are pure new directories; two touch
shared core files.

Clean adoptions:
- deal-finder (sonichi#657), whatsapp (sonichi#771), context-drop (sonichi#902/sonichi#907)
- screen-companion (sonichi#794/sonichi#801/sonichi#816) — manifest skill, auto-discovered
  by loadSkillManifestTools(); imports CP-5's vision-tools.

Zoom move (sonichi#827):
- new skills/zoom/tools.ts — summon / dismiss / join_zoom relocated
  out of meeting-tools.ts per the CLAUDE.md inline-tool-location rule
- meeting-tools.ts stripped to joinGmeet / lookupMeetingId / callContact
- inline-tools.ts re-exports the Zoom trio from the skill

Discord voice (sonichi#783):
- new skills/discord-voice/ — standalone TS voice-channel server
  (discord.js + @discordjs/voice + bodhi VoiceSession) + share-screen
- package.json: +discord.js, @discordjs/voice, @discordjs/opus,
  libsodium-wrappers; package-lock regenerated, all deps load (incl.
  native @discordjs/opus)
- browser-tools.ts: switch_tab description disambiguated vs share_screen

Typecheck clean, 176 tests pass. README integrations-table tweak from
sonichi#783 skipped (fork README diverged).
Adopts upstream's diagnostics + memory-sync work. The fork never
independently modified any of these skills (only an old upstream merge
touched them), so upstream's versions are adopted wholesale — clean.

- self-diagnose (sonichi#768, sonichi#727): cross-node gather over SSH
  (gather-remote.sh), gh-resolution fix when miniconda shadows it.
- cross-node-sync (sonichi#758): MEMORY.md regenerator (regenerate-memory-index.py)
  auto-called after sync. Also lands the sonichi#791-deferred doc reframes
  (SKILL.md, merge-voice-metrics.sh, setup-rsync-sync.sh).
- sync-memory (sonichi#659, sonichi#764, sonichi#755): scripts/sync-memory.sh — memory+notes
  rsync; default ~/.sutando/memory-sync/ with legacy auto-migration;
  .env sourced from workspace root.
- docs/memory-sync.md, scripts/stage-readiness.sh.
- .gitignore: !tests/*.test.sh so the new shell tests are tracked.

npm test still 176 pass. New shell tests (sync-memory-migration,
gather-remote) aren't wired into `npm test` (it runs .test.ts + .py
only) — sync-memory-migration verified manually, 5/5 pass.
README integrations tweak skipped (fork README diverged).
Small isolated fixes across inline/browser tools + skills.

inline-tools.ts:
- type-text (sonichi#660/sonichi#661/sonichi#664): clipboard-paste for non-ASCII; UTF-8
  locale + stdin pbcopy (launchd locale fix); `append` flag.
- press_key (sonichi#673): uparrow/downarrow/arrowup/... aliases — bare names
  fell through to literal keystroke.
- cancel-task (sonichi#663): writes a CANCEL_INSTRUCTION task into the pipeline
  instead of just unlinking; CLAUDE.md gets the matching handler note.

browser-tools.ts:
- scroll (sonichi#672): amount param (small/medium/large → 150/400/800px).
- open_url (sonichi#721/sonichi#919): reuse active tab on same-origin; reject
  whitespace / zero-width chars early; query-redacted failure logging.

skills:
- screen-record (sonichi#662/sonichi#667/sonichi#668): mic preference, silent-audio warning,
  ffmpeg stderr capture — adopted, fork's bundled-ffmpeg (bare `ffmpeg`)
  re-applied.
- tts (sonichi#691): quote-strip in synthesize.sh (openai-tts + gemini-tts).
- morning-briefing (sonichi#690): gws calendar instead of removed script.
- schedule-crons (sonichi#732): example cron points at scripts/sync-memory.sh.

Typecheck clean, 176 tests pass.
…-api

First slice of CP-11 (app/startup/init). Heavy CX checkpoint — the
self-contained pieces:

- watch-tasks (sonichi#693/sonichi#736): retire one-shot watch-tasks.sh; CLAUDE.md
  documents the Monitor + watch-tasks-stream.sh pattern. sonichi#736
  (SUTANDO_WORKSPACE TASKS_DIR) already satisfied — fork's
  watch-tasks-stream.mjs honors it.
- startup.sh (sonichi#698): macOS 15+ Screen Recording denial detection
  (silent black-PNG → size gate); gate screen-capture launch on PERM_OK.
- startup.sh (sonichi#780): git committer attribution from stand-identity.json.
  sonichi#911 (LOGS_DIR redirect) already done in the fork.
- agent-api.py + dashboard.py (sonichi#728): absolute /usr/bin/{pgrep,git,pmset}
  for launchd-spawnable code paths. sonichi#729 (/logs/voice path) already
  satisfied — fork uses state_path().
- launchd (sonichi#914): health-check fallback log → $WORKSPACE/logs.
- start-cli (sonichi#688): tmux mouse-scroll. schedule-crons SKILL.md,
  tmux-status test: adopted.

Deferred to CP-11 part 2: init.sh sonichi#913 (workspace runtime dirs) +
the matching tests/init-script.test.ts fix, main.swift sonichi#694/sonichi#699
(pause-loop submenu, Timer cadence), Info.plist sonichi#807.

176 tests pass.
…tcher cadence

Finishes CP-11 of the sonichi/sutando → AG2Platform/stando upstream sync.
Applied surgically — the fork diverged from upstream on every touched file.

- sonichi#913 init.sh: workspace-rooting was already done in the fork; ported only
  the missing one-time migrate_legacy_runtime_state() sweep, adapted to the
  fork's STATE_ROOT + repo/workspace split (notes/, build_log.md,
  pending-questions.md stay repo-rooted — cross-fleet-shared).
- sonichi#694 main.swift: Pause Loop submenu (30m / 1h / indefinite) via shared
  writePauseSentinel(); new pauseSentinelActive() guard added to the bodies
  of refreshContextualChips() and runHealthCheck() so paused windows also
  skip startup one-shot fires.
- sonichi#699 main.swift: checkWatcher Timer 30s → 300s; dropped the now-dead
  120s lastWatcherAlert throttle.
- sonichi#807: no change needed — app/Info.plist already declares
  NSAppleEventsUsageDescription and build-app.sh seals it at codesign.
- tests/init-script.test.ts: adopt a separate .workspace dir (closes the
  deferred sonichi#791 test fix) + 3 new legacy-migration tests.

Verified: bash -n + swiftc -parse clean; npm test 179/179 pass; init.sh
migration smoke-tested (moves stale state, idempotent on second run).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-homes the four web-client.ts features the fork lost when src/web-client.ts
was deleted for the React client. Surgical port — upstream's inline-HTML
implementations rebuilt as React components/hooks against the fork's
client/ structure.

- sonichi#650 markdown: marked + dompurify added; lib/chat-markdown.ts renders
  sanitized markdown in ConversationStream bubbles (finalized user/assistant
  turns only — system pills + interim streams stay plain text). Separate
  from lib/markdown.ts, which is notes-trust-only and must not render
  untrusted chat content.
- sonichi#826 capabilities: static "What I can do" panel on the idle conversation
  screen (CapabilitiesPanel atom + const-values/capabilities.ts).
- sonichi#651 subscriptions: subscription-scanner skill ported into skills/ (it
  was missing from the repo); web-server.ts gains GET /paidsubscriptions/data
  + localhost-gated POST /paidsubscriptions/scan; new /subscriptions React
  route with summary cards + sortable table.
- sonichi#735 vision: web-server.ts /vision/* proxy to the voice-agent control
  server (127.0.0.1:7848); useVision hook drives getDisplayMedia capture +
  JPEG frame loop; Watch pill in the top bar + bottom-right VisionPreview.
- sonichi#804 /presenter: excluded per the user — not ported.

Verified: pnpm client build clean; tsc --noEmit clean; npm test 179/179.
Live integration testing (vision streaming, scan task pickup) needs the
full voice-agent stack — see .claude/5-19-2026-sync/09-manual-test-guide.md.

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

# Conflicts:
#	.gitignore
#	CLAUDE.md
#	README.md
#	package-lock.json
#	package.json
#	skills/cross-node-sync/scripts/regenerate-memory-index.py
#	skills/phone-conversation/scripts/conversation-server.ts
#	skills/screen-record/scripts/record.py
#	skills/subscription-scanner/SKILL.md
#	src/Sutando/main.swift
#	src/agent-api.py
#	src/archive-stale-results.py
#	src/browser-tools.ts
#	src/cartesia-tts.ts
#	src/dashboard.py
#	src/discord-bridge.py
#	src/event_log.py
#	src/github-webhook.py
#	src/health-check.py
#	src/init.sh
#	src/inline-tools.ts
#	src/screen-capture-server.py
#	src/session-handoff.sh
#	src/slack-bridge.py
#	src/startup.sh
#	src/task-bridge.ts
#	src/telegram-bridge.py
#	src/vision-tools.ts
#	src/vision_push.py
#	src/voice-agent.ts
#	src/voice-context.ts
#	src/watch-tasks-stream.sh
#	src/web-client.ts
#	tests/agent-state-endpoint.test.ts
#	tests/slack-bridge-tier-map.test.py
Upstream sync phase 2 — CP-4 through CP-7 + Slack settings UI
Upstream sync phase 3 — CP-8–CP-12, conversation-store, and the upstream merge
- docs/features.md — full catalog of Sutando capabilities after the
  CP-0–CP-12 upstream sync (surfaces, built-in tools, 42 skills,
  vision, memory, observability, architecture).
- docs/skill-test-plan.md — concrete trigger/expected checklist for
  live-testing the synced features.
docs: feature reference + skill test plan
check_core_proactive_loop() read core-status.json from the workspace
root, but PR sonichi#940 relocated it to state/core-status.json (see CLAUDE.md
"Work Status" and the proactive-loop step-0 instruction). The check
therefore read a stale pre-relocation orphan at the root and emitted a
"core-proactive-loop ... last heartbeat > 600s ago" warning on every
pass — a permanent false positive that masked the real signal.

Point status_path at WORKSPACE_DIR / "state" / "core-status.json".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /status and /answer handlers resolved pending-questions.md via
personal_path("pending-questions.md", REPO_DIR). personal_path tries
$SUTANDO_MEMORY_DIR/machine-<host>/<file> then <workspace>/<file> — but
the workspace arg was REPO_DIR (the repo checkout root), not the real
workspace. The file lives at <workspace>/pending-questions.md, so it
resolved to a non-existent path and /status always returned an empty
`questions` array. The web UI Questions panel was therefore always
empty, hiding every pending question from the user.

Resolve the workspace via resolve_workspace() (same as
check-pending-questions.py) and pass it to both call sites. Repo-bundled
public assets (stand-avatar.png, stand-identity.json) keep REPO_DIR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The core-status.json relocation fix (this branch) made
check_core_proactive_loop read WORKSPACE_DIR/state/core-status.json,
but health-check-stuck-loop.test.py still wrote its fixtures to the
workspace root — so the check saw no file and returned "ok / not yet
written" instead of the expected "warn" (case e failed). Point the
write_status() helper and the malformed-JSON case at state/.

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

fix(workspace): correct core-status.json and pending-questions.md paths
The upstream-sync merge (#38) added @anthropic-ai/claude-agent-sdk to
package.json but regenerated package-lock.json (npm) instead of
pnpm-lock.yaml — the repo's actual lockfile. CI's
`pnpm install --frozen-lockfile` then failed with ERR_PNPM_OUTDATED_LOCKFILE.

Regenerated pnpm-lock.yaml via `pnpm install`; frozen-lockfile install
now passes.
fix(ci): update pnpm-lock.yaml for @anthropic-ai/claude-agent-sdk
`regenerate-memory-index.py:default_memory_dir()` derived the Claude Code
project slug from `Path(__file__).resolve().parent` walked up four levels.
When `skills/` is an app-bundle symlink (the normal install layout),
`.resolve()` follows it into `/Applications/Sutando.app/.../repo` — so the
memory dir pointed at a location with no frontmatter files, `collect_entries`
returned 0 entries, and the clobber guard turned the regen into a silent
no-op on every cross-node-sync cron pass (every 7 min).

Fix:
- Derive the slug from `resolve_workspace()` (the canonical workspace
  contract: $SUTANDO_WORKSPACE / ~/.sutando/workspace), never from __file__.
- `_slugify_path()` now replaces every non-alphanumeric char (the old code
  replaced only `/`, leaving the dot in `.sutando` and producing a slug that
  did not match Claude Code's actual project directory).

Adds tests/regenerate-memory-index-workspace-resolution.test.py (3 cases:
slugify, env-var precedence, workspace-not-__file__ resolution). All pass;
`py_compile` clean.

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

CLAassistant commented May 22, 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 commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author

Retargeting to AG2Platform/stando fork (proactive-loop PRs land on the fork). Reopening there.

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