This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Repository navigation
Conversation
Adds the same env-var-driven UI override that sutando's web-server has (see josh/migrate-to-react on sonichi/sutando, commit c350a10). With this in place, stando can serve any UI bundle by exporting: CLIENT_DIST_DIR=/abs/path/to/dist bash src/startup.sh before launching the voice agent. Without the env var, behavior is unchanged — stando serves its bundled client/dist/ as before. Phase 1.7 of the OSS sync roadmap. Unblocks the workflow where production stando deployments override the default UI with a private branded dist while developer / demo builds still serve the stando default UI from client/. Mechanics ported verbatim from sutando: - resolveClientDistDir() function with tilde expansion - module-level `let CLIENT_DIST_DIR = resolveClientDistDir()` so existing references keep working - per-call re-resolve inside startWebServer() so tests + dynamic env changes pick up the latest value - JSDoc updated to document the override Submodule auto-init block from sutando's startup.sh deliberately NOT ported — stando's client/ is still vendored (not a submodule), and the goal is independence from OSS UI updates, not auto-sync. Co-authored-by: Cursor <cursoragent@cursor.com>
Ports sutando/tests/client-dist-dir-override.test.ts verbatim so the private repo's pluggability contract stays in lockstep with OSS. Same 3 cases: custom dir override serves, default fallback serves bundled UI, path traversal blocked. All 3 pass against the new resolveClientDistDir() in src/web-server.ts. Co-authored-by: Cursor <cursoragent@cursor.com>
The React frontend (client/) is moved out of this repo into the private AG2Platform/stando-ui repo (full history preserved via git subtree split) and plugs back in via CLIENT_DIST_DIR — the same pluggability seam ported in #68. Stando's backend and the UI now ship and version independently; the private UI no longer has to track the OSS sutando UI. Removals: - client/ (97 files) — now lives at AG2Platform/stando-ui - package.json: build:client / dev:client scripts - pnpm-workspace.yaml: client workspace member (lockfile re-pruned) Rewiring so everything still works: - web-server.ts: default dist resolves to ../client/dist (present only in the packaged app, staged by build-app.sh); a dev checkout sets CLIENT_DIST_DIR or gets a 503 with a build hint. Docs/JSDoc updated. - startup.sh: auto-detects a built sibling ../stando-ui/dist when CLIENT_DIST_DIR is unset; otherwise warns. Override always wins. - app/build-app.sh: sources the UI from CLIENT_DIST_DIR or a sibling stando-ui checkout (builds it), stages it at the bundle's client/dist path so the shipped app stays self-contained with no runtime env var. - .github/workflows/release.yml: checks out + builds AG2Platform/stando-ui and passes its dist via CLIENT_DIST_DIR. REQUIRES a new repo secret STANDO_UI_TOKEN (PAT with read access to the private UI repo) — the default GITHUB_TOKEN can't reach another repo. - docs/WIRE.md: the wire contract any UI must honor (ported from OSS). - .env.example / CONTRIBUTING.md / .gitignore: documented + cleaned up. Verified: tsc clean (pre-existing @ai-sdk/openai error unrelated), the CLIENT_DIST_DIR test passes 3/3, frozen lockfile installs, and the server serves the real stando-ui dist end-to-end (200 + hashed assets). Co-authored-by: Cursor <cursoragent@cursor.com>
refactor: extract conversation UI to private stando-ui repo
Phase 5.2 of the OSS→stando backend reconciliation. Ports the one
genuinely-missing, infra-backed web-server endpoint from OSS sutando.
/presenter — exposes the presenter-mode sentinel
(state/presenter-mode.sentinel, written by scripts/presenter-mode.sh and
already polled by the Discord/Slack/Telegram bridges) to the React UI.
The UI was already designed for it: the existing /voice-mode comment
references "the presenter badge poll to render the composite 3-mode
badge", but the endpoint didn't exist. Returns { active, expiresAt };
past/garbage/missing expiry → inactive (matches the bridges' is_active
checks). Reads via statePath('state/presenter-mode.sentinel'), the same
workspace path resolve_workspace() gives the bridges.
Deliberately NOT ported (OSS-specific, no stando infra / feature
decisions, not endpoint hardening): /chat (standalone chat page),
/overlays + /api/overlays (overlay-apps manager — no overlay infra in
stando), /paidsubscriptions* (subscription-scanner web UI — skill exists
but no web rendering layer).
Also removes tests/tasks-api.test.ts: it imported
../client/src/lib/tasks-api.ts, which PR #69 moved to the stando-ui repo.
The test is orphaned in stando (code gone) — coverage belongs in
stando-ui now.
Verified: tsc clean; new tests/web-server-presenter.test.ts 4/4; full TS
suite 244/244.
Co-authored-by: Cursor <cursoragent@cursor.com>
Phase 5.3 of the OSS->stando backend reconciliation. agent-api.py's route set already matches OSS 1:1 and from_agent sanitization already exists, so the only genuine gap was get_task_result(): it checked live results/ and tasks/ but not results/archive/<YYYY-MM>/. task-bridge archives a result within ~seconds of delivery, so a web UI GET /result/<id> poll can arrive after the move and 404 a task that actually completed. Port the OSS archive-glob fallback (newest month first); /tasks/active already scanned the same layout, so this just brings /result/ to parity. Live results/ still takes precedence over the archive. Preserves stando's deliberate divergences (resolve_workspace/state_dir path layout left untouched). Verified: py_compile clean; new tests/agent-api-result-archive.test.py 3/3; full Python suite 49/49; agent-api-task-field-injection still green. Co-authored-by: Cursor <cursoragent@cursor.com>
Port the only clean OSS sutando win from the voice-agent.ts reconciliation (Phase 5.4): add the "[BLANK_AUDIO]" token to the FILLERS-ARE-NOT-REQUESTS backchannel list so a transport-emitted blank-audio marker is ignored rather than (mis)interpreted as a directive that triggers `work`. The rest of OSS's voice-agent.ts diff was reviewed and intentionally NOT ported: stando is ahead on multi-provider voice/STT + the cloud gateway, uses state-paths.js (not workspace_default.js) by design, and the read_selection deictic prompt depends on a tool stando doesn't have. Co-authored-by: Cursor <cursoragent@cursor.com>
…points feat(web-server): /presenter endpoint (Phase 5.2)
fix(agent-api): /result finds archived results (Phase 5.3)
feat(voice-agent): port [BLANK_AUDIO] filler token (Phase 5.4)
Phase 5.5 of the OSS→private sync. Ports `src/single_instance.py`
verbatim from OSS sutando and adopts it in the three bridges so a
duplicate process (the canonical case: `startup.sh` racing
Sutando.app's launchd agent at boot) exits cleanly instead of running
side-by-side and double-delivering tasks / racing API tokens.
Mechanism: OS-level `fcntl.LOCK_EX | LOCK_NB` on a workspace-local
lock file (`<workspace>/state/locks/<name>.lock`). Auto-released by
the OS when the holder process dies (no manual cleanup). Contended
acquirers exit 0 (NOT 1) so launchd KeepAlive doesn't restart-loop
on the second instance.
Adoption is one line per bridge:
- telegram-bridge.py / slack-bridge.py: first line of `main()`.
- discord-bridge.py: at the top of the `client.run(...)` branch
(NOT the `send` REST-shortcut branch — `dm-result.py`-style
one-shot sends must keep working alongside a running bridge).
Verified:
- tests/single-instance.test.py — 4 cases (lock-create, contention
exits 0, lock-release on holder death, distinct names don't
contend).
- Existing bridge tests still pass (telegram-tofu, telegram-allowlist,
slack-access, discord-allowlist).
- `pnpm typecheck` clean (no TS surface touched).
This phase explicitly chose only single_instance.py from the 12
OSS-only files; the rest (result_channel_key, task_archive,
voice-key, voice-config*, send_allowlist, discord_config,
overlay-manager-ui, morning-briefing, obsidian-mirror) are either
covered inline in stando, conflict with stando's architecture, or
are net-new features that deserve their own dedicated phase.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(bridges): single-instance flock guard for telegram/discord/slack (Phase 5.5)
…ilure no-restart
Phase 5.7 of the OSS→private sync. Three targeted hardenings ported from
OSS sutando's health-check.py, classified after a full per-function diff
audit (the larger voice-transport stuck-CONNECTING rework + sutando-app
auto-launch were deliberately deferred to their own phase).
1. check_discord_voice() — on-demand process detector for
skills/discord-voice/scripts/discord-voice-server.ts. Mirrors the
existing conversation-server pattern: soft `warn` when down (the
steady state — process spawns per voice session and is SIGTERM'd by
`dismiss`), `ok` + stale-vs-source check when running. Detail
contains "running" in both states so the dashboard service filter
surfaces it either way.
2. check_notes_split_brain() — fires when both <repo>/notes/ and
<workspace>/notes/ exist with overlapping .md filenames. Edits to
one side become invisible to the other; the probe surfaces the
overlap + the migrate-script hint. No-op when the two paths resolve
to the same dir (single-dir installs).
3. main() --fix branch — short-circuit when a discord-bridge /
telegram-bridge detail mentions LoginFailure or "token invalid":
print "regenerate at discord.com/developers/applications (no
restart)" instead of running subprocess.Popen against a known-bad
credential. Pre-port, --fix would crash a fresh bridge against the
same revoked token AND (if launchd is also managing one) create a
duplicate process — the exact failure mode the Phase 5.5 single-
instance lock now catches, but cheaper to avoid up front.
Verified: - tests/health-check-phase-57.test.py — 9 cases (3× discord-voice
states, 3× notes-split-brain states incl. same-path no-op, 2×
LoginFailure short-circuit + non-regression for normal stale
restart, 1× run_all_checks() wiring smoke).
- Existing tests/health-check-stuck-loop.test.py +
tests/health-check-emit-task.test.py still pass.
- tests/single-instance.test.py (Phase 5.5) still passes.
- `pnpm run typecheck` clean (no TS surface touched).
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(health-check): discord-voice + notes split-brain probes, LoginFailure no-restart (Phase 5.7)
Three coordinated changes that close the REST-fallback file-delivery
gap and remove the drift hazard between the WS-bridge and dm-result.
1. src/send_allowlist.py (new, verbatim port from OSS sutando)
- Extracts SEND_ALLOWED_ROOTS, SEND_ALLOWED_PREFIXES, and the
realpath-based is_path_sendable() sanitizer into one module.
- Identical policy values to the prior inline copy in
discord-bridge.py — this is a pure refactor, not a tightening
or loosening.
- Per the liususan091219 review on the OSS PR sonichi#1029: keeping the
policy as a copy in each consumer drifts even with "keep in
sync" comments. Pre-extract on stando, the dm-result inline
copy was already missing the notes/Desktop/Documents roots
that discord-bridge had.
2. src/dm-result.py: multipart upload path
- Adds _send_message_with_files() (multipart/form-data POST to
/channels/{id}/messages with payload_json + files[N] parts).
- send_dm() now extracts [file:|send:|attach:] markers,
allowlist-checks each path via the shared is_path_sendable,
chunks the text as before, then uploads sendable files in
batches of 10 (Discord per-message cap).
- Pre-port: dm-result stripped markers and logged them as
dropped because REST multipart wasn't implemented. Owner
missed every file delivered when voice was disconnected.
- Filename sanitization mirrors discord-bridge's
_safe_attachment_basename (CR/LF/quote -> _) so a filename
can't inject its own multipart headers.
3. src/discord-bridge.py: adopt the shared helper
- Replaces inline SEND_ALLOWED_ROOTS / SEND_ALLOWED_PREFIXES /
_is_path_sendable with `from send_allowlist import ...`.
- Local name `_is_path_sendable` preserved as an alias to the
shared function so every existing sink call site (4 sites
across DM/voice/proactive paths) stays unchanged.
Tests
-----
- tests/send-allowlist.test.py (new): policy semantics (nonexistent
rejects, directory rejects, allowed prefix matches, arbitrary
rejects, symlink/realpath collapse defeats path-injection,
dot-dot traversal rejects) + identity-level drift guards that
both consumers reference the SAME tuple/function objects (a copy
that equal-compared at module-load would silently drift later) +
the documented-prefix set guard.
- tests/dm-result-upload.test.py (new): 5 regression guards for
the new upload flow — sendable file -> text+multipart, disallowed
file -> text-only no upload, file-only body -> multipart with
empty content, >10 files -> 2 batches at Discord cap, multipart
body shape (boundary + payload_json + files[N] parts + filename
sanitization on the wire).
- tests/discord-bridge-allowlist.test.py (updated): structural
sanitizer regression guard now inspects send_allowlist.py for
the body (#1-#4) and verifies discord-bridge.py imports +
aliases the helper (#5) before re-checking the discord.File()
gate (#6). Same CodeQL-style guarantees; follows the
implementation.
Verification
------------
- python3 -m py_compile on all three modified source files: OK
- New tests: 14 cases, all green.
- Existing regression sweep: discord-bridge-{allowlist,
file-markers, file-send-allowlist, pending-replies, prose-marker,
reply-chunks, state-prefetch, access-tier}, dm-result-send-dm,
bridge-marker-no-leak, result-markers, discord-chunker,
single-instance, agent-api-result-archive, health-check-phase-57,
task-bridge-isvoice, web-server-presenter,
client-dist-dir-override -> 19 Python + 15 TS, all green.
- `pnpm typecheck` clean.
Phase 5.9 of the OSS->private sync. No behavioral change to the
WS-connected bridge — the alias preserves every existing call
site. The user-visible win is on the REST-fallback path: files
emitted while voice is disconnected now actually reach the DM.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(dm-result): multipart upload + shared send_allowlist (Phase 5.9)
Three coordinated changes that close the OSS->stando port of the owner-resolution unification started in Phase 5.9. Pre-Phase-5.10 both `dm-result.py:_resolve_owner_id` and `discord-bridge.py:_poll_proactive` carried inline copies of the same env -> tierMap -> bot-filter walk chain. The drift class @liususan091219 flagged on OSS PR sonichi#1029 — and that bit production in sonichi#846 — was waiting to recur. This PR funnels both sites through one helper. 1. src/discord_config.py (new, verbatim port from OSS sutando) - load_config / save_config / resolve_owner_id / auto_seed_if_missing. - resolve_owner_id chain: env override -> discord-config.json[owner] -> discord-config.json[tierMap] -> access.json[owner] (legacy) -> access.json[tierMap] (legacy sonichi#846 path). Returns None when exhausted; helper deliberately does NOT fall through to allowFrom[0] (the sonichi#1147 bug path) — callers do that step paired with their own bot-filter walk. - auto_seed_if_missing: idempotent. On first boot writes <workspace>/state/discord-config.json seeded from access.json. Emits WARN when seed has to fall back to allowFrom[0] so the operator catches a mis-seed. 2. src/dm-result.py: rewire _resolve_owner_id - Replaces the ~57 line inline chain with a delegate-to-helper pattern. Helper handles steps 1-5; bot-filter REST walk stays here (helper is sync-only, so async/sync split between the two callers stays clean). 3. src/discord-bridge.py: rewire _poll_proactive owner block + boot - on_ready now calls discord_config.auto_seed_if_missing on the parsed access.json. Idempotent; non-fatal on error. - Owner-resolution block in poll_proactive replaces ~42 inline lines with a discord_config.resolve_owner_id call followed by the existing async bot-filter walk. Preserves the list-order fix (`load_allowed()` set->list change from the 2026-05-18 msze_/qingyunwu mis-route). Net effect on diff with OSS upstream ------------------------------------ - discord-bridge.py: -42 +30 in the proactive block (closer to OSS). - dm-result.py: -57 +30 in the resolver (closer to OSS). - discord_config.py: byte-identical to OSS (zero conflict surface going forward). - Total: ~70 lines of stando-specific inline chain replaced with delegate-to-shared-helper calls. The diff against upstream SHRINKS on the two existing files. Pattern matches Phase 5.9's send_allowlist extract. Tests ----- - tests/discord-config.test.py (new, 15 cases): resolution chain semantics (env / workspace owner / workspace tierMap / legacy owner / legacy tierMap / exhaustion -> None / stale-tier-for- delisted-user safety), file IO safety (load on missing, load on corrupt JSON, save_config atomic replace, auto_seed idempotency, auto_seed legacy-owner path, auto_seed allowFrom[0]-fallback WARN), and drift guards that both consumers import the helper AND call the right entry points. - tests/dm-result-send-dm.test.py (updated): `_with_access_json` now also isolates `discord_config.config_path()` so the resolver doesn't consult the dev's live workspace discord-config.json (which would short-circuit the legacy chain the tests pin). Verification ------------ - python3 -m py_compile on all three modified source files: OK. - New tests: 15 cases, all green. - Full Phase 5 regression sweep: 23 Python tests green (discord-bridge-{allowlist, file-markers, file-send-allowlist, pending-replies, prose-marker, reply-chunks, state-prefetch, access-tier, discord-state-detection, welcome-on-first-post, reply-directive, mod-judge, mod-judge-actions}, dm-result-{send-dm, upload}, discord-config, send-allowlist, bridge-marker-no-leak, result-markers, discord-chunker, single-instance, agent-api-result-archive, health-check-phase-57). - `pnpm typecheck` clean. Phase 5.10 of the OSS->private sync. Behavioral note: stando's workspace already contains a `discord-config.json` (OSS code seeded it during an earlier run); the helper picks it up immediately. For fresh installs, auto_seed_if_missing runs on the first bridge on_ready and seeds the file idempotently. Co-authored-by: Cursor <cursoragent@cursor.com>
feat(discord-config): shared owner-resolution helper (Phase 5.10)
Stando's telegram-bridge and discord-bridge both use atomic-claim-by-
rename for proactive-delivery — `results/proactive-*.txt` is renamed
to `proactive-*.sending` before the actual send to prevent same-tick
double-deliveries between concurrent poll iterations. But if the
bridge process crashes between the rename and the send (SIGKILL,
OOM, unhandled exception in the send path), the `.sending` file is
orphaned in `results/` — no poll iteration looks at `.sending`
suffixes, so the owner's proactive notification is silently dropped
until manual intervention.
This PR ports OSS's startup recovery sweep
(`_recover_orphan_sending_files`) to both bridges. On bridge boot,
scan `results/` for orphan `proactive-*.sending` files and rename
them back to `*.txt` so the next poll iteration picks them up.
Changes
-------
- src/telegram-bridge.py: add `_recover_orphan_sending_files` (37
lines) before `main()`; call it from `main()` after
`_single_instance_acquire`, before the polling loop.
- src/discord-bridge.py: add the same function (47 lines, with the
richer docstring from OSS) before `on_ready`; call it from
`on_ready` after the Phase 5.10 auto-seed block, before the
poll-task creation.
Functions are byte-identical to OSS sutando's versions (modulo the
"Mirrors discord-bridge.py" / "Mirrors telegram-bridge.py" docstring
swap, which points each at the other for cross-bridge consistency).
Pattern matches the OSS comment chain.
Tests (tests/bridge-orphan-sending-recovery.test.py, 9 cases)
-------------------------------------------------------------
1. Behavior parity — both bridges recover 2 orphan files end-to-end.
2. Drift guards — both bridges DEFINE the function AND call it from
their startup paths (regex-checked against `main()` and
`on_ready()`).
3. Selectivity — only `proactive-*.sending` is touched; `task-*.sending`,
`proactive-*.txt.tmp`, and existing `.txt` files pass through.
4. Collision safety — if `<name>.sending` AND `<name>.txt` both exist,
the .sending is left in place (no clobber).
5. Idempotency — second call with no orphans returns 0; missing
RESULTS_DIR also returns 0 (boot before any results materialize).
Minimal-divergence accounting
-----------------------------
- Diff impact on telegram-bridge.py: -0 +56. Pure additive.
Closes a stando→OSS divergence point: OSS had this function,
stando didn't.
- Diff impact on discord-bridge.py: -0 +59. Pure additive.
Closes a stando→OSS divergence point: OSS had this function,
stando didn't.
- Function bodies are byte-identical to OSS upstream. Zero conflict
surface against future OSS pulls.
- Net effect: stando's bridges are now byte-closer to OSS on a
~115-line gap that previously had no equivalent code on the
stando side.
Verification
------------
- python3 -m py_compile on both modified bridges: OK.
- New tests: 9 cases, all green.
- Full Phase 5 regression sweep: 27 Python tests green
(discord-bridge-{allowlist, file-markers, file-send-allowlist,
pending-replies, prose-marker, reply-chunks, state-prefetch,
access-tier, discord-state-detection, welcome-on-first-post,
reply-directive}, dm-result-{send-dm, upload}, discord-config,
send-allowlist, bridge-marker-no-leak, result-markers,
discord-chunker, single-instance, telegram-bridge-{allowlist,
access, tofu, forward-attribution, proactive-owner-resolution},
agent-api-result-archive, health-check-phase-57, plus this PR's
new test).
- `pnpm typecheck` clean.
Phase 5.11 of the OSS->private sync. The other OSS additions to
telegram-bridge.py audited in this phase (the `task_id` parameter
on `send_reply` and the `task_archive.find_task_file` import) were
NOT ported: the first is an additive enhancement that mostly
benefits OSS's outbox_log telemetry rather than stando's
_emit_channel_metric pipeline, and the second is a dependency of
the `claim_task` rename pattern stando does not use. Both can be
revisited if the underlying systems converge later.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(bridges): recover orphan .sending files on startup (Phase 5.11)
….12) Extends the recovery sweep from Phase 5.11 to slack-bridge.py, closing the same orphan-`.sending` bug class on every proactive-delivery surface (telegram, discord, slack). When the bridge crashes between the atomic claim-rename (`proactive-*.txt` → `.sending`) and the actual send, the .sending file sits orphaned in `results/` because no poll iteration ever looks at .sending suffixes — the owner notification is silently dropped. Changes: - `src/slack-bridge.py`: add `_recover_orphan_sending_files()` (byte- identical to telegram/discord versions modulo the docstring's cross-reference) and call it from `main()` immediately after the single-instance lock, before any threads spin up. - `tests/bridge-orphan-sending-recovery.test.py`: extend coverage to slack — add basic recovery, selectivity, collision-safety, and idempotent/missing-dir variants; rename the drift-guard tests to cover all three bridges; stub `slack_bolt` + dummy SLACK_*_TOKEN env so the module imports under test without the real library. Per the minimal-divergence directive, only the additive recovery function is ported in this PR. Two other portable hardenings surfaced during the audit are queued as follow-ups: - access-cache for sonichi#899 (slack-bridge access.json external-deletion fix) - `send_allowlist` helper extract (parallel to Phase 5.9 for discord) Tests: 55/55 Python tests pass (12/12 in the orphan-recovery file, 4 of which are net-new for slack). TypeScript typecheck clean. Phase 5.12 of the OSS → private sync. Co-authored-by: Cursor <cursoragent@cursor.com>
feat(slack-bridge): recover orphan .sending files on startup (Phase 5.12)
…ase 5.13, sonichi#899) Before this PR, deleting ~/.claude/channels/slack/access.json out from under a running slack-bridge — Sutando.app's Settings UI revoke flow, accidental rm, sync conflict — caused the next inbound Slack DM to fall straight into TOFU and silently overwrite the prior `tofuOwner` / `tierMap` / manually-added `allowFrom` entries with a fresh single-user payload. Effectively re-onboarded whoever sent the next message as the new owner. Fix: mirror the file into an in-memory cache on every successful read, and consult the cache before genuine TOFU. If access.json is missing but the cache holds a valid prior state (tofuOwner present), restore the file from cache instead of TOFUing. Genuine first-time TOFU still works when the cache is empty. OSS sutando has had this guard for some time; we now match. Changes: - `src/slack-bridge.py`: - Add `_access_cache`, `_access_cache_mtime`, `_access_cache_lock` module-level state. - Add `_update_access_cache(data)` — writes-through to the lock- guarded cache, captures the file's mtime so `load_tier_map` can short-circuit on a hot path. - Add `_restore_access_from_cache()` — writes the cached payload back to ACCESS_FILE at 0o600 perms, only when cache has tofuOwner. Fail-open on per-call errors. - `load_allowed`: call `_update_access_cache(data)` after successful read. - `load_tier_map`: consult cache first (mtime-validated), fall back to re-read + update on miss. - `tofu_onboard`: try `_restore_access_from_cache()` before genuine TOFU; call `_update_access_cache(payload)` after writing. Tests: - New `tests/slack-bridge-access-cache.test.py` (10 tests): - load_allowed populates cache w/ mtime - load_tier_map cache hit (mtime match) vs miss (mtime change) - _restore_access_from_cache writes back at 0o600 - _restore refuses without tofuOwner / empty cache (corruption guard) - tofu_onboard recovers from external deletion (the sonichi#899 case) - tofu_onboard genuine first-time TOFU when cache empty - drift guards: module surface + source structural call-site checks - `tests/slack-bridge-access.test.py`: bump tofu_onboard regex budget 2000 → 4000 chars (function grew by docstring + restore-check; same pattern this test already used twice for _write_task). Pre-existing structural guard, unchanged semantics. Verification: - 56/56 Python tests pass (was 55; +1 net-new test file). - TypeScript typecheck clean. - `python3 -m py_compile src/slack-bridge.py` clean. Phase 5.13 of the OSS → private sync. Closes one of the two queued follow-ups from PR #80; `send_allowlist` extract for slack still queued as Phase 5.14 candidate. Co-authored-by: Cursor <cursoragent@cursor.com>
…cache fix(slack-bridge): cache access.json to survive external deletion (Phase 5.13, sonichi#899)
…urvives restarts (Phase 5.14) Completes the Phase 5.13 sonichi#899 cache port. The cache machinery only protects against external deletion of `access.json` if the cache is actually populated. Phase 5.13 added the cache + restore plumbing but missed the 1-line OSS pairing that primes the cache on bridge startup — without it, `_access_cache` stays `None` until the first successful `load_allowed()` call. If that first call happens to be the access check for an inbound DM that arrives AFTER an external deletion (file deleted while the bridge was down, then a message lands), the cache is still `None`, `_restore_access_from_cache()` returns False, and `tofu_onboard()` silently overwrites the prior owner — exactly the regression sonichi#899 was meant to prevent. OSS sutando has had this priming since the original sonichi#899 fix; the Phase 5.13 PR ported the cache surface but not the startup wiring. Changes: - `src/slack-bridge.py`: in `main()`, call `load_allowed()` immediately after `_recover_orphan_sending_files()` and before any threads spin up. Reads access.json into the in-memory cache while the file is still on disk, so the very first inbound DM after a restart can detect and recover from external deletions. - `tests/slack-bridge-access-cache.test.py`: new `test_main_primes_access_cache_at_startup` structural guard. Verifies `main()` contains `load_allowed()` AND that it appears AFTER `_recover_orphan_sending_files()` in source order. A future PR that drops the priming or reorders it would silently regress sonichi#899. Audit summary: - Original Phase 5.14 scope was a `send_allowlist` helper extract for slack-bridge. Audit found OSS sutando still has the SAME inline allowlist as stando — the extract was done only for the discord family (discord-bridge + dm-result). Extracting slack would have ADDED divergence, violating the minimal-divergence rule. Scope was redirected to the actual remaining gap: the missing sonichi#899 priming call. After this PR, slack-bridge has converged with OSS structurally — no extant net-portable wins. Verification: - 56/56 Python tests pass (no test count change; cache-test file grew by 1 net-new test). - TypeScript typecheck clean. - `python3 -m py_compile src/slack-bridge.py` clean. Phase 5.14 of the OSS → private sync. Closes the slack-bridge backlog. Co-authored-by: Cursor <cursoragent@cursor.com>
…lowlist fix(slack-bridge): prime access cache at startup so sonichi#899 fix survives restarts (Phase 5.14)
….15)
`src/outbox_log.py` is the audit log every bridge appends to after a
successful outbound delivery. Stando has had the module + tests for
some time, and `slack-bridge.py` already records to it on every reply
(line 575) — but the data was invisible. Dashboard had no reader and
no rendering card, so the writes accumulated as a JSONL file no one
read. OSS sutando ships both pieces; this PR ports the missing half.
Changes:
- `src/dashboard.py`:
- Add `get_outbox(limit: int = 10) -> list[dict]` reader. Imports
`outbox_log` lazily so its failure cannot break dashboard rendering
(telemetry is a courtesy, never load-bearing); returns `[]` on any
exception.
- Add the Outbox card to the render flow between Capabilities Matrix
and Keyboard Shortcuts. Hidden when empty via `if outbox:` — fresh
installs and bridges that haven't recorded anything yet shouldn't
see a stub card. Renders channel-typed emojis (slack/discord/
telegram/imessage/whatsapp/email/x), truncated timestamps,
recipient labels, and 80-char body previews — identical shape to
OSS sutando.
- `tests/dashboard-outbox.test.py` (new, 6 tests):
- Happy path: append entries via outbox_log, read back via get_outbox.
- Limit param honored + default (10) applied.
- Fresh-install (no file) returns [] not exception.
- Fault tolerance: stub outbox_log.read_recent to raise → get_outbox
returns [] without propagating the exception.
- Structural source guard: `if outbox:` gate on the render block.
- get_outbox is a module-level callable (not a closure / hidden symbol).
Audit context:
- Inventory of remaining src/ Python files identified `dashboard.py`
as a 117-line divergence with one OSS-only def: `get_outbox`. Other
diff lines are deliberate stando-specific path-resolution (state_paths
vs workspace_default, personal_path signature, DASHBOARD_PORT env
override) — those were NOT touched per the minimal-divergence rule.
- Writers beyond slack-bridge (discord-bridge had ~4 outbox_log.append
sites in OSS) are deferred — they'd be much larger ports and require
audit of which call sites are still relevant in stando's flow.
Verification:
- `python3 tests/dashboard-outbox.test.py` → 6/6 pass.
- Full Python regression sweep → 57/57 pass (was 56; +1 net-new file).
- TypeScript typecheck clean.
- Smoke test of the live reader pulled 5 prior entries from the
existing outbox file — confirms the wiring is correct.
Phase 5.15 of the OSS → private sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(dashboard): add outbox card to surface bridge audit log (Phase 5.15)
Phase 5.15 added the dashboard reader + Outbox card. Slack writes were already populating outbox_log, so slack deliveries became visible right away. Discord deliveries still didn't, because discord-bridge.py had no `outbox_log.append(...)` calls. Ported the three OSS sites that cover discord's main delivery paths. Sites ported (each `try/import outbox_log/append/except Exception: pass` to keep telemetry fail-open and non-load-bearing): 1. `poll_results` reply path — every channel.send() reply (DM or guild channel). channel_type derived from `discord.DMChannel` check; recipient_label = "<recipient> DM" or "#<channel-name>". 2. Proactive owner-DM path — every dm.send() proactive notification. channel_type="discord_dm"; recipient = owner_id; recipient_label from fetched user.name. 3. dm-fallback channel-redirect path — every target_channel.send() when a [channel: <id>] marker routes a result to a non-originating channel. channel_type="discord_channel"; recipient = the resolved target_channel_id (NOT the owner_id), so the dashboard reflects where the message actually landed. Two stando-specific send sites were NOT ported and don't have OSS equivalents: - L2225 welcome message to new guild user (stando-only feature) - L2705 cap-hit reply for cloud-cap accounting (stando-only) Adding outbox writes for those would be a feature addition, not a port — out of scope for the minimal-divergence sync. Tests (new tests/discord-bridge-outbox.test.py, 5 cases): - Structural: exactly 3 append sites, each wrapped in `try/import outbox_log/append/except Exception: pass`. - Variable binding: each append carries channel_type, recipient, recipient_label, body, task_id. - Channel-type vocabulary stays in dashboard._channel_icon's known set (catches typos that would silently render the default → arrow). - Round-trip: writes via outbox_log are read back via the dashboard reader added in Phase 5.15. - Drift guard: every append is preceded by a _chunk_for_discord send loop within 30 lines (prevents future PR from recording empty-body audit rows when the bridge only sent files). Verification: - 58/58 Python tests pass (was 57; +1 net-new file). - TypeScript typecheck clean. - `python3 -m py_compile src/discord-bridge.py` clean. Phase 5.16 of the OSS → private sync. Discord deliveries now show up alongside slack in the dashboard Outbox card. Co-authored-by: Cursor <cursoragent@cursor.com>
…x-records feat(discord-bridge): record outbox audit on every delivery (Phase 5.16)
….17) Closes bridge-family symmetry: Phase 5.15 added the reader (dashboard Outbox card), Phase 5.16 added discord-bridge writes; this phase ports the OSS telegram-bridge write site. Changes: - `src/telegram-bridge.py`: `send_reply(chat_id, text)` gains a `task_id: str | None = None` parameter. After the chunked text send, `outbox_log.append(channel_type='telegram', recipient=str(chat_id), body=clean_text, task_id=task_id)` is called inside a try/except — telemetry is best-effort and must never block a reply. The task-reply call site in `main()` passes `task_id=task_id` through; the proactive caller continues to omit it (matches OSS behavior). - `tests/telegram-bridge-outbox.test.py` (new, 7 tests): - Structural: exactly 1 append site, wrapped in try/except. - Signature: send_reply accepts task_id with None default. - Caller: task-reply site plumbs task_id=task_id through. - Variable-binding: append carries channel_type, recipient, body, task_id. - channel_type is "telegram" (matches dashboard _channel_icon map →✈️ ). - Round-trip: append → outbox_log → dashboard.get_outbox (isolated tmp workspace). - Positioning: append follows text-send loop, inside if clean_text: gate. 27/27 Python tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ox-records feat(telegram-bridge): record outbox audit on every reply (Phase 5.17)
…ase 5.18)
Closes the bridge-family outbox coverage gap. Phase 5.16 added
outbox_log.append calls to discord-bridge.py for live-bridge deliveries;
this phase ports the OSS dm-result.py write site for the REST-fallback
path used when the live bridge isn't connected. Without this PR, an
offline-bridge owner DM would land successfully on Discord but never
appear in the dashboard's Outbox card alongside live-bridge rows —
silently missing every delivery during bridge downtime.
Changes:
- `src/dm-result.py`: after the success-state print "sent to DM"
and before the final `return True` in `send_dm`, append an outbox
audit row wrapped in `try / import outbox_log / append / except
Exception: pass`. channel_type="discord_dm" matches discord-bridge's
live-bridge vocabulary so both surfaces render the same 💬 icon in
the dashboard card. recipient_label is the constant
"owner DM (via dm-result.py)" — a deliberate divergence from
discord-bridge's per-user label, used as an audit-trail marker
letting operators distinguish REST-fallback rows from live-bridge
rows at a glance.
- `tests/dm-result-outbox.test.py` (new, 6 tests):
- Structural: exactly 1 append site, fail-open wrapped.
- Channel-type vocabulary: must be `discord_dm` (matches discord-
bridge writes so both surfaces share the same icon).
- Variable-binding: required kwargs present (channel_type, recipient,
body, recipient_label).
- Recipient-label marker: must be the dm-result audit marker so
operators can distinguish surfaces.
- Positioning: append sits AFTER the "sent to DM" success print
and BEFORE the final `return True` (catches a refactor that
hoists it above an error branch — would record rows for unsent
messages — or moves it past the return — unreachable).
- Round-trip: append → outbox_log → dashboard.get_outbox reader
picks it up with the right channel_type + marker.
Verification:
- 60/60 Python tests pass (was 59; +1 net-new file).
- TypeScript typecheck clean.
- `python3 -m py_compile src/dm-result.py` clean.
Phase 5.18 of the OSS → private sync. Bridge → outbox → dashboard
pipeline is now feature-complete across slack + discord (live bridge +
REST fallback) + telegram.
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(dm-result): record outbox audit on REST-fallback DM delivery (Phase 5.18)
…ando)
`pending-questions.md` mixes two formats:
- structured: `## Title` + `- **Status:** unanswered`
- free-form prose: `## Title` + plain prose body (deleted when resolved,
not marked done)
Before this PR, `get_waiting_questions()` required an explicit Status
field, so the free-form prose style was silently dropped — a question
typed directly by the user would never trigger a notification, a Discord
DM ping, or a voice result. Effectively, anyone using the documented
prose convention was invisible to the notifier.
OSS sutando fixed this by treating a missing Status field as default-
unanswered (matching the documented "sections are deleted when resolved"
behavior), while still skipping sections whose Status is explicitly
"resolved" / "done" / "answered". This PR ports that change verbatim —
the function body is now byte-identical to OSS.
Tests cover:
1. free-form prose → reported as waiting (regression target)
2. explicit `Status: unanswered` → reported
3. explicit `Status: Waiting` → reported
4. explicit `Status: resolved/done/answered` → skipped (5 variants)
5. empty title → skipped (defensive)
6. mixed file (real-world layout) → only unanswered sections reported
7. empty file → empty list, no crash
Verification: 61/61 Python tests pass (60 prior + 1 new), TS typecheck clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
…ions-no-status-field Phase 5.19: notify on no-Status-field pending questions (sync OSS sutando)
Replaces the single-table conversation-store schema with per-surface
tables (voice / phone / discord_voice) plus an idempotent migration
from the legacy schema, mirroring OSS sutando. Stando's `.app`-bundle-
compatible lazy `require('node:sqlite')` pattern is preserved (OSS
uses a static import).
Public API is unchanged — `recordConversation(role, text, sessionId)`,
`recordSessionBoundary(reason, sessionId)`, and `recordSession(metrics)`
keep identical signatures so all existing callers (voice-agent.ts,
task-bridge.ts, skills/phone-conversation/.../conversation-server.ts)
continue to work without rewiring. Three additive exports land:
- sourceFromRole(role) — role-prefix → table-keyed source
- kindFromRole(role) — legacy role → per-surface kind taxonomy
- recordToolCall(source, name, durationMs, sessionId) — per-tool-call
sink so tool invocations land in the surface table immediately
(kind='tool_call') instead of being batched at session end via
sessions.tool_calls JSON. Survives crashes, kill -9, ngrok drops.
Schema details:
- Surface tables (voice/phone/discord_voice): identical shape, each
holds utterances + tool calls in one chronological stream
(id INTEGER PRIMARY KEY ordering; kind discriminates row type).
- sessions/session_events tables retained — different concerns.
- sessions.tool_calls and sessions.events JSON columns dropped post-
init (the atom rows now live in surface tables / session_events).
- New views: v_voice, v_phone, v_discord_voice, v_sessions — thin
wrappers adding a human-readable `time` column for DB Browser.
- Backwards-compat `conversation` VIEW that unions the three surface
tables under the legacy `role` column name so external scripts
(query-conversation.sh, regression-search) keep working without
source edits.
Migration:
- Runs once on first init when legacy `conversation` or `tool_calls`
tables are present and any surface table is empty.
- Single transaction; failure rolls back and logs (best-effort).
- NULL ts_unix rows are dropped (can't satisfy NOT NULL surface
schema; no other column carries time).
- Idempotent — second open is a no-op (legacy tables already dropped).
- Also backfills discord_voice from sessions.tool_calls JSON via
json_each (discord-voice never wrote to the standalone tool_calls
table in the legacy schema).
Stando-specific divergence from OSS (intentional, documented):
- `import type { DatabaseSync }` + lazy `require('node:sqlite')` in
init(). Older bundled Node runtimes gate node:sqlite behind
--experimental-sqlite; a static import would throw
ERR_UNKNOWN_BUILTIN_MODULE and crash every process that imports
conversation-store. The lazy path lets the mirror degrade to a
no-op while the text conversation.log keeps working.
- Issue numbers (sonichi#1052 etc.) replaced with neutral references
("the per-surface refactor") so comments don't carry OSS-specific
PR ids into stando.
Test coverage (tests/conversation-store-per-surface.test.ts, 20 cases):
- sourceFromRole + kindFromRole routing contract (incl. core-agent
→ agent suffix-matching, edge: 'shared-user' is not prefixed).
- recordConversation routes by role-prefix; rows don't leak across
surface tables.
- recordToolCall writes kind='tool_call' to the named surface table;
durationMs=null lands as NULL.
- recordSessionBoundary writes SESSION_END row.
- recordSession's session_events fan-out filters duplicate-prefix
events (user:/sutando:/tool_call:/etc) — surface tables are canonical.
- End-to-end migration via child-process runner: realistic pre-
refactor seed (conversation + tool_calls + sessions w/ JSON cols)
→ assert per-surface row counts, kinds, post-migration view shape.
- Migration idempotency: second open adds only the trigger row, no
duplicate backfill.
- Backwards-compat view: SELECT FROM conversation still works.
- SessionMetrics back-compat: legacy toolCalls field is silently
accepted (interface stability for existing callers).
Verification: 264/264 TS tests pass (244 prior + 20 new), 61/61 Python
tests pass, TS typecheck clean.
Why minimal divergence: the functional code in src/conversation-store.ts
is now identical to OSS sutando modulo the documented lazy-require
divergence + neutral-issue-number wording. Future OSS changes to this
file should merge cleanly without semantic conflicts.
Co-authored-by: Cursor <cursoragent@cursor.com>
…store-per-surface Phase 5.20: conversation-store per-surface tables (sync OSS sutando)
Task files place `task:` last, with the user-supplied body following. `parse_priority_from_text` scans line-by-line for `priority:` headers. Before this PR it stopped only on `---` or a blank line — not on `task:`. A forged body line `priority: urgent` after `task:` was still matched, escalating priority via the task-body injection vector. OSS sutando closes this by stopping at the first `task:` line (everything after is opaque body). This PR ports that one-line behavioral change verbatim. The function body is now identical to OSS. Also updates the existing task-priority.test.py header fixture to place `priority:` before `task:` (the writer convention), and adds task-priority-stop-at-task-delimiter.test.py with 6 regression cases. Verification: 62/62 Python tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
…-stop-at-task Phase 5.21: stop parse_priority at task: delimiter (sync OSS sutando)
SKILL_DIR.parents[1] walked up from skills/deal-finder/ to the repo root, so RESULTS_DIR resolved to <repo>/results/ instead of the workspace path. The Telegram bridge polls ~/.sutando/workspace/results/ — deal-finder notifications were silently dropped since the bridge never saw them. Fix: rename the variable to REPO_DIR (its actual meaning), import resolve_workspace() from src/, and derive RESULTS_DIR from the workspace. .env lookup also corrected (repo-local file, REPO_DIR is right there). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced Jun 5, 2026
bassilkhilo-ag2
approved these changes
Jun 8, 2026
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Workspace path fix correct. WORKSPACE renamed to REPO_DIR where it means the repo root, resolve_workspace used for results dir. Same bug class as 106/107/109. CI green.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Problem
skills/deal-finder/scripts/scan.pyusedSKILL_DIR.parents[1]to derive aWORKSPACEvariable. Walking up fromskills/deal-finder/lands at the repo root, so:The Telegram bridge polls
~/.sutando/workspace/results/for proactive results files. Deal-finder notifications were written to<repo>/results/and silently dropped — the bridge never saw them.Same bug class as PR #106 (voice-agent), PR #107 (conversation-server), PR #109 (dashboard).
Fix
WORKSPACE→REPO_DIRto reflect its actual meaningresolve_workspace()fromsrc/and deriveRESULTS_DIRfrom the actual workspace.envlookup toREPO_DIR / ".env"(it's a repo file — that was already right, just using the wrong name)Test plan
python3 skills/deal-finder/scripts/scan.py --dry-run— should not crashRESULTS_DIRprints as~/.sutando/workspace/results/🤖 Generated with Claude Code