This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Repository navigation
test(workspace-default): isolate fallback-path tests from local config override - #115
Open
bassilkhilo-ag2 wants to merge 323 commits into
Open
bassilkhilo-ag2 wants to merge 323 commits into
bassilkhilo-ag2 wants to merge 323 commits into
Conversation
…onichi#991 review (sonichi#1024) * test(workspace): tighten state-paths adoption gate per qingyun-wu's PR sonichi#991 review ## Why — closes 5 issues @qingyun-wu identified post-merge on sonichi#991 The first version (PR sonichi#991) of this test passed exactly the incidents it was meant to prevent. @qingyun-wu's review identified five specific gaps; this rewrite addresses each. 1. **Recommended fix pointed at a module that doesn't exist.** The v1 error message told contributors to use `state_paths.state_dir` / `state-paths.stateDir` — but `src/state_paths.py` / `src/state-paths.ts` don't exist in this repo (they exist in a different fork's vocabulary). The v2 message only points at `workspace_default` / `resolve_workspace` — both real modules here. 2. **Import-presence is necessary but not sufficient.** A file can import the resolver for path X and still hand-roll `Path(__file__).parent.parent / "tasks"` for path Y. @qingyun-wu's concrete example: `src/health-check.py` legitimately does both (separate concerns: workspace state vs. checkout). The v1 test green-lit any file with an import; v2 adds a POSITIVE anti-pattern check that fires on composition shapes like `Path(__file__).parent.parent / "tasks"` regardless of whether the resolver is also imported. 3. **Quote/slash-fragile regex.** v1 only matched double-quoted `"tasks"` and slash-wrapped `'/tasks/'`/`"/tasks/"`. It missed single-quoted bare `'tasks'` (common in TS) and template literals `` `${ws}/tasks/${id}` `` (the dominant TS path-building form). v2 covers both — see `RUNTIME_STATE_REGEX` and the new `test_runtime_state_regex_self_check`. 4. **`.tsx` files never scanned.** v1's runner called `SRC.rglob("*.ts")` only. v2 iterates `*.py` + `*.ts` + `*.tsx`. 5. **Dead code.** v1 defined `_allowlisted_or_missing` and never called it. v2 removes it. ## New structure - `RUNTIME_STATE_REGEX` — broadened to match single-quoted bare, template literals, and `${X}/tasks/` interpolations. - `HAND_ROLLED_COMPOSITION` — new regex for the documented incident shape: `Path(__file__).parent.parent / "<token>"` or `path.join(REPO_ROOT, '<token>')` etc. - `_is_comment_line` — skips pure comment/docstring lines so the legitimate prose mentions of `~/.sutando/workspace` in module docstrings don't false-positive. - 7 test functions (up from 3): the main scan, the two sanity assertions kept from v1, plus 4 self-checks that pin the regex behavior (POSITIVE matches for documented incident shapes, NEGATIVE for known false-positive forms like `req.on('data')`). ## Acknowledgements Co-authored-by: qingyun-wu via post-merge review of upstream PR sonichi#991. Every gap closed in this rewrite traces directly to a numbered observation in that review. Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> * test(workspace): drop unused FALLBACK_DEFINITION regex per @liususan091219 review Lucy's review on this PR flagged FALLBACK_DEFINITION as dead code — defined but never wired into `_check_file`. The inline comment on `HAND_ROLLED_COMPOSITION`'s `path.join(REPO_ROOT, …)` branch claimed the regex was used as a cross-check, but the wire-up was missing. Two options were possible: wire it in, or drop it. Dropping is the cleaner call — the reserved variable names `REPO_ROOT` / `repoRoot` / `FALLBACK_ROOT` are themselves the signal we want to flag. Anything named `WORKSPACE_DIR` / `workspace` / `repo` is caught by the earlier alternation in `HAND_ROLLED_COMPOSITION`, and those names already imply legitimate-vs-fallback usage well enough. Updated the inline comment on that branch to document the convention that gates this distinction by name rather than by cross-checking a separate regex. No behavior change — the regex wasn't used, so removing it doesn't affect any check. Co-Authored-By: liususan091219 (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> --------- Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
…shell injection (sonichi#1022) ## The bug (security — RCE) Two-stage chain that lets a Discord user achieve arbitrary code execution on the owner's machine: ### Stage 1: discord-bridge saves attachment filenames verbatim `src/discord-bridge.py` (two sites): local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}" Discord allows arbitrary filenames: spaces, quotes, semicolons, backticks, `$`, `..`. An attacker uploads: x"; touch /tmp/pwn; #.jpg The bridge saves it as /tmp/discord-inbox/1779392000000_x"; touch /tmp/pwn; #.jpg ### Stage 2: conversation-server fast path shell-splices the path `skills/phone-conversation/scripts/conversation-server.ts:310`: const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...') .toString().trim(); const result = execSync( `bash .../prepend-image.sh "${image}" "${video}" 3`, ...); When the owner is on a phone call and says "prepend image to video" (triggers the concat fast path), the `image` glob picks up the malicious filename. execSync uses `/bin/sh -c`: bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3 The first arg to bash is `"/tmp/discord-inbox/..._x"` — closing `"` ends the argument. Then `; touch /tmp/pwn` runs, then `; #.jpg" ...` is a comment. Concrete RCE from a Discord DM attachment + a single voice trigger from the owner. ## Fix — two layers ### Layer A: sanitize at the boundary `src/discord-bridge.py`: new `_safe_attachment_basename()` helper. Mirrors `_safe_id` from `src/agent-api.py` — keep alphanumerics + `._-`; replace everything else with `_`; strip path-traversal; preserve short extensions for glob matching; cap length to bound DoS via multi-kilobyte filenames. Applied at both `att.filename` save sites. ### Layer B: defense-in-depth at the use `skills/phone-conversation/scripts/conversation-server.ts`: switch the prepend-image fast path from execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`) to const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`; execFileSync('bash', [scriptPath, image, video, '3'], ...); `execFileSync` bypasses `/bin/sh -c`; each argv entry is a separate arg to `bash` so no value spliced into argv gets interpreted as shell syntax. Closes the injection class regardless of what the filename contains. Also expands `~` to `$HOME` (execFileSync doesn't run via shell, so `~` wouldn't be expanded). ## Tests 8 cases in `tests/discord-bridge-attachment-filename-sanitize.test.py`: - normal filenames preserved - shell-injection chars stripped - backtick + `$` stripped - path-traversal stripped - empty/pure-special → fallback name - common extensions preserved (glob matching) - Unicode replaced - overlong filename capped 3 cases in `tests/conversation-server-fastpath-execfile.test.ts`: - execFileSync imported - execFileSync('bash', [...]) pattern present - raw execSync(`bash ... "${image}"`) pattern absent All 11 pass locally. `npm run typecheck` clean. Python syntax clean. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…sting (sonichi#1042) * fix(tests): restore main CI — _isVoiceTask fixture order + import hoisting Two post-sonichi#1023 test-only fixes; main has been CI-red since sonichi#1023 merged. 1. tests/task-bridge-isvoice.test.ts — VOICE_BODY / NON_VOICE_BODY had `task:` first, headers after. Pre-sonichi#1023 the reader used `.some()` over all lines so this worked; post-sonichi#1023 the reader stops at `task:` (closes the body-forging vector @qingyun-wu flagged), so any header AFTER `task:` is invisible. Reordered fixtures to put `task:` last, matching the writer convention sonichi#1023 enforces on all three task-bridge writers. 2. tests/task-bridge-stop-at-task-delimiter.test.ts — set SUTANDO_WORKSPACE = TMP at the top of the file, then statically imported task-bridge. ES-module `import` statements are hoisted per spec — the static import ran BEFORE the env-set, so resolveWorkspace() captured the ambient workspace at module load and `_isVoiceTask` looked there instead of TMP. Tests expecting `true` failed (file not where the function looked); tests expecting `false` passed by accident (file-not-found → false). Switched to top-level `await import(...)` so the import runs in source order, after the env is set. Verified: typecheck clean; 13/13 cases in both files pass; full suite goes from 7 failing → 0 failing on these two suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): stub HOME so discord-bridge module load works on clean-install CI The test added by sonichi#1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`, but `src/discord-bridge.py` reads the token from `~/.claude/channels/discord/.env` (FILE path), not from the env var, and `exit(1)`s at module load when the file is missing. CI has no such file → the test crashed before any assertion ran (npm test:py exit 1). Stub HOME to a temp dir + write a fake `.env` with the token. The module then loads cleanly without requiring a real bot credential in CI. Test logic itself is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): task-priority fixture order — task: last, per sonichi#1023 `tests/task-priority.test.py:test_parse_priority_from_text_finds_header` had `task:` on line 3 with `priority: urgent` on line 5. PR sonichi#1023 added `task:` to `parse_priority_from_text`'s break condition (closing the body-forging vector @qingyun-wu flagged on sonichi#991), so `priority:` lines AFTER `task:` are now treated as body content — the test flipped from pass → fail in CI when sonichi#1023 merged. Reorder the fixture: `task:` last, header fields before. Test logic unchanged. Same class of fixture-order bug as the two test files already fixed in this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi#1039) New skill: a standalone, dependency-free local service that tracks running Claude Code (and other) agent instances. stdlib-only HTTP service + SQLite store, a CLI client (register/heartbeat/deregister/list/watch with --autostart), and a Claude Code SessionStart hook for self-registration + heartbeat. Not the AG2 Workforce Hub app — a thin local-first service that can optionally mirror state to a hub later. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
* feat(web-client): overlay manager view + control-server proxy Adds an /overlays management view listing the desktop overlay applications with open/close/show/hide and opacity/always-on-top controls. web-client proxies /api/overlays/* to the overlay app's control server (located via a discovery file), so the browser needs no CORS or port handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): add electron-overlay-dimming pattern skill Documents the focus-based auto-dimming pattern for Electron overlay windows — dim on app blur, restore to configured opacity on focus — with the deferred-blur-check and effective-opacity techniques and a reference implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(overlay-apps): bundle the Electron overlay app as a skill Brings the desktop overlay application (AI Benchmarks, System Resources, Hub overlays) into the repo as a skill. Source of truth is skills/overlay-apps/app/; launch.sh syncs it to $SUTANDO_WORKSPACE/overlay-apps/ — code in the repo, runtime instance + node_modules in the workspace, per the workspace contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…ask-file template (sonichi#1019) ## The bug The `/meeting` POST handler composes a task file via template literal: const platform = (body.platform ?? 'zoom').toLowerCase(); const originalId = body.meetingId.trim(); ... const taskContent = `id: ${taskId}\ntimestamp: ${...}\ntask: Sutando joined meeting ${originalId || digits} (${platform}) — call SID ${sid}.`; Both `platform` and `originalId` are user-controlled (Gemini tool arguments) and neither was newline-sanitized: - `.toLowerCase()` doesn't touch CR/LF. - `.trim()` only strips leading/trailing whitespace, not embedded. A value like `{ platform: "zoom\nchannel_id: local-voice", … }` would forge a `_isVoiceTask` line-scan match downstream and misroute the task — same class as the agent-api `/task` injection closed by PR sonichi#982. Reachable from any local process that can hit /meeting, which includes the voice-agent itself when Gemini synthesizes a tool call with attacker-controlled meeting metadata from user voice input. ## Fix Sanitize both at the source (single point of contact): - platform: .toLowerCase().replace(/[\r\n]/g, ' ').trim() - originalId: body.meetingId.trim().replace(/[\r\n]/g, ' ').slice(0, 80) CR/LF flatten to single spaces (no information loss for valid inputs). 80-char cap on originalId bounds the task-file size against an oversized-input DoS. ## Tests `tests/conversation-server-meeting-injection.test.ts` (5 cases) — source-grep architectural assertions covering the strip, the cap, no raw body.* in the template, and the positive use of sanitized locals. All 5 pass locally. `npm run typecheck` clean. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…loses sonichi#1040 (sonichi#1041) Rsyncing a live SQLite database corrupts it. The three rsync calls in the data-sync section copied conversation.sqlite (+ its .sqlite-wal and .sqlite-shm sidecars) across nodes; with the writer active, this produced B-tree damage that `PRAGMA integrity_check` reports as "2nd reference to page" / "rowid out of order" / "btreeInitPage error". Add `--exclude 'conversation.sqlite*'` (glob covers .sqlite + -wal + -shm) to all three rsync calls — peer→staging pull, staging→local copy, and local→peer push — plus a doc paragraph above the section spelling out the hazard and pointing at `.backup`-snapshot-based sync as the right shape if cross-node sqlite consolidation is added later. The data-sync mtime-wins flow was also never an actual merge of two sqlite dbs (the merger loop only handles .jsonl) — just a clobber, so excluding it loses no real cross-node feature. `bash -n` clean (no test exists for setup-rsync-sync.sh; the change is three single-line `--exclude` additions + a comment). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…loses sonichi#798) (sonichi#818) Add setSessionToolUpdater/callUpdateTools/callRestoreTools hooks in vision-tools.ts so skills can restrict the live VoiceSession's tool surface without importing voice-agent.ts. voice-agent.ts registers the updater after session creation (accessing transport.updateTools via `any` cast — GeminiLiveTransport private API, applied on next reconnect). Clears it on shutdown. activate_screen_companion now calls callUpdateTools() to restrict the session to tools_allow + always-retained set (activate/deactivate). A new deactivate_screen_companion tool calls callRestoreTools() to lift the restriction. tools_enforced field in activation payload confirms whether hard enforcement fired (or fell back to advisory). SKILL.md Trust + scope section updated to reflect enforcement is now hard, not advisory.
…sonichi#1050) Matches telegram-bridge.py pattern. Allows test suites to inject a stub token via os.environ without needing a real ~/.claude/channels/ discord/.env file — unblocks CI for VasiliyRad PRs sonichi#1046 and sonichi#1048 whose tests already set the env var before importing the bridge. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
… workspace_default (sonichi#1055) `read-quota.py` lives at <repo>/skills/quota-tracker/scripts/read-quota.py and is reached via the ~/.claude/skills symlink, so `Path(__file__).resolve()` points into the repo — four levels deep. `_REPO_ROOT` used three `.parent` calls, landing on <repo>/skills (which has no `src/`), so the `from workspace_default import status_read_path` silently hit the bare `except` → `_canonical = None` → "No quota-state.json found." Quota read as missing regardless of where the proxy actually wrote the file. The TS proxy's relative `../../../src` already resolves to <repo>/src; this brings the Python reader in line. Verified: with the fix, `read-quota.py` prints the live 5h/7d windows. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sonichi#1057) New user-invocable skill `skills/db-browser/` — `/db-browser <path>`, macOS only. Installs DB Browser for SQLite via Homebrew if missing (`brew install --cask db-browser-for-sqlite`) and opens the .sqlite file at the given path. Quits any running DB Browser first when switching files so the new file isn't shadowed by the previous open's cached snapshot. Scope is deliberately tight: install + open. No screenshot, no SQL, no schema inspection — anything else is the operator's job in the GUI. Default path when invoked with no argument: $SUTANDO_WORKSPACE/data/ conversation.sqlite (falling back to ~/.sutando/workspace/data/ conversation.sqlite). Works on any .sqlite file when given an explicit path. Used routinely during Sutando operations to inspect the per-surface conversation tables (voice / phone / discord_voice from sonichi#1051) without hand-writing SQL. Single SKILL.md, no scripts/ directory needed — install + open is a 2-command flow. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
… writes (sonichi#1051) * refactor(conversation-store): per-surface tables + realtime tool_call writes Split the single `conversation` table into per-surface utterance tables (voice / phone / discord_voice) keyed by source. Each row carries `kind ∈ {user, agent, peer, tool_call, SESSION_END, …}`, `text`, `duration_ms`, `session_id`. Tool calls are first-class rows in the same surface table — written in real time from each server's onToolResult hook (no longer batched at session end), so they're visible in DB Browser mid-session and survive a session that never cleanly ends. The legacy `conversation` and `tool_calls` tables are migrated on init (idempotent backfill, then dropped). Pre-migration old-schema `discord_voice` (from the multi-instance branch) is renamed to `discord_voice_legacy` and backfilled, then dropped. Added 4 thin convenience views — `v_voice` / `v_phone` / `v_discord_voice` / `v_sessions` — each adds a human-readable `time` column (local TZ) and default-sorts by ts_unix DESC, replacing the older v_* views that read from session_events with prefix-encoded names. Also fixes a voice-agent session-lifecycle bug (src/voice-agent.ts:1342-1370): the `handleClientConnected` reset guard keyed on `clientHasConnectedOnce`, which missed the case where a phantom server-idle session-end fires `writeVoiceMetrics()` BEFORE the first real client ever connects. That flush set `metricsWritten=true`, and the real user's later session-end then returned early — record lost. Now keyed on `metricsWritten` itself, so any stale flush triggers a reset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address sonichi#1051 review: compat conversation view + update legacy readers Per @qingyun-wu's review on sonichi#1051 — dropping the conversation table left two consumers unupdated: scripts/query-conversation.sh and skills/regression-search/scripts/diagnose-call.py both still SELECT role FROM conversation, which fails post-migration twice (table gone AND role renamed to kind). CI is green only because neither is exercised in the test suite. Fix per review suggestion (belt + suspenders): 1. Add a backward-compat `conversation` view to conversation-store.ts init() — UNION ALL across voice/phone/discord_voice with `kind AS role`. Any leftover reader I missed keeps working with no source edit. DROP VIEW IF EXISTS in the same block as the v_* views so re-init is idempotent. 2. Update both flagged readers to query the per-surface tables natively with the new `kind` column. This drops their dependency on the compat view so future schema work can move freely; the view stays as a safety net for downstream consumers (anyone running their own analysis scripts against conversation.sqlite). diagnose-call.py's _build_transcript also gains a kind != 'tool_call' filter — the prior query relied on no tool_call rows existing in the conversation table; post-fan-out, tool_call rows would have leaked into the human-readable transcript without that. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…onichi#1049) ## The bug If the agent crashes mid-task with non-idempotent side effects already executed (Discord message sent, file written, API call made), but the archive of result + task files never ran, on restart the task file is still in `tasks/`. The watcher re-emits it and the agent re-processes — potentially re-executing the same side effect. This is the informal idempotency contract that's been implicit across the codebase. This PR ships a signal from watcher → agent: "I think this is a retry." ## Fix `src/task_bump_attempts.py` — Python helper that atomically bumps the `attempts:` counter on a task file. Wired into `src/watch-tasks-stream.sh` (both initial sweep AND fswatch stream branch) so every emission increments the counter: - Fresh task drop → `attempts: 1` inserted. - Re-emit (watcher restart on leftover file, or burst-event re-emit past fswatch dedupe) → counter advances. Insertion point: BEFORE the `task:` delimiter line. Task-file parsers across the codebase stop scanning at `task:`, so anything after it is invisible — the counter must precede. Atomic write: tmp + rename. Bumper failure is non-fatal (logs to stderr, exits 0; the watcher still emits the file). ## What agents should do Read the `attempts:` field. If `> 1`, treat as a retry: - Check delivery sentinels before re-sending messages. - Inspect prior outputs (`results/` archive) for the same task_id and skip or fold duplicate work. - Log the retry for observability. This PR ships the contract, not the agent-side handling. Per-side- effect checks are scoped per-task follow-ups. ## Tests `tests/task-bump-attempts.test.py` (9 cases): - Fresh task → attempts: 1 inserted (BEFORE task: line) - Existing attempts: 1 → 2 - High count (47 → 48) - Malformed file (no task: line) → untouched - No .tmp leak after atomic write - Three sequential bumps → attempts: 3, still one line - Missing file → no error - CLI invocation works (the watcher calls it via python3) - Architectural pin: bumper called from BOTH initial-sweep AND fswatch stream branches All 9 pass locally. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…afety) (sonichi#1047) ## The bug Discord gateway disconnect that outlasts the RESUME window forces discord.py into a full IDENTIFY reconnect. IDENTIFY does NOT replay `MESSAGE_CREATE` events that arrived during the gap — they're LOST. The window where this bites is short: any network blip / ngrok hiccup that exceeds the RESUME window (tens of seconds) is enough. Symptom is silence — the bridge never sees the message, no operator signal, recovery requires manual REST fetch. ## Fix Per-channel checkpoint in `state/discord-dm-checkpoint.json` mapping `channel_id → last_observed_message_id`. Discord message IDs are Snowflake-monotonic so `after=<id>` is a reliable "since" cursor. - `_update_dm_checkpoint(channel_id, message_id)` — atomic write via tmp+rename; advances forward-only. - `_load_dm_checkpoint()` — fail-open: missing/corrupt → `{}`. Filters malformed entries. - `_handle_discord_message` — advances the checkpoint for every DM observed (before downstream filters drop). Prevents the catch-up loop from re-replaying non-owner DMs forever. - `_catchup_missed_dms()` — coroutine scheduled from `on_ready`; for each checkpointed DM channel REST-fetches messages with `after=<last_id>` and dispatches each through `_handle_discord_message`. Bounded at 50 messages per pass. `on_ready` fires on IDENTIFY (not RESUME), so catch-up triggers exactly when prior-session gaps could have produced losses. ## Tests `tests/discord-bridge-dm-catchup.test.py` (10 cases): checkpoint storage semantics (load/update/atomic/forward-only/filter) + architectural source-grep wiring assertions (`_catchup_missed_dms` scheduled from `on_ready`, `_handle_discord_message` calls `_update_dm_checkpoint`). Full integration of the catch-up loop is deferred — the wiring assertions catch the most common regression class, and a real discord.py harness adds complexity beyond the bug's blast radius. All 10 pass locally. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…licate events (closes sonichi#1052) (sonichi#1054) Builds on sonichi#1051. Three storage paths in conversation.sqlite were triple- encoding the same atoms — per sonichi#1052's table. This PR collapses them so surface tables (voice / phone / discord_voice) and session_events become the single sources of truth; the sessions table keeps only the per- session rollup metadata. Schema migration (idempotent, init-time): - ALTER TABLE sessions DROP COLUMN tool_calls (now in surface tables as kind='tool_call' rows, written real-time per sonichi#1051) - ALTER TABLE sessions DROP COLUMN events (now in session_events as lifecycle-only rows, see below) - Guarded via pragma_table_info — no-op on re-run. Writer-side (3 surface servers + store): - recordSession() INSERT drops the 2 JSON columns (13→11 cols) - recordSession()'s session_events fan-out now skips user:/caller:/ sutando:/assistant:/tool_call:/tool_result: prefixes (defense-in-depth filter for any external caller still passing them in m.events) - voice-agent.ts removes 3 voiceEvents.push: tool_call: (onToolCall), tool_result: (onToolResult), and the user:/sutando: utterance loop in the turn-handler. logConversation() + recordToolCall() are the canonical writes for those. - conversation-server.ts (phone) removes the same 4: tool_call:, tool_result:, caller:, sutando: - discord-voice-server.ts removes the same 4: tool_call:, tool_result:, user:, sutando: - Lifecycle pushes (session_started, session_ended, error, task_*, channel_result, rec_indicator, call_ended) are preserved. Reader-side: - diagnose-call.py (skills/regression-search) rebuilds the tool_calls list from surface tables (UNION ALL 3 with kind='tool_call' filter by session_id) and the events list from session_events directly. The output shape is unchanged. SessionMetrics interface kept toolCalls + events fields for backwards compat with existing callers — they're now silently ignored (toolCalls) or filtered (events) instead of persisted as JSON blobs. Verified end-to-end: - Migration applied to live workspace db; sessions now has 11 columns; pre-existing data preserved. - All 3 surface servers restart cleanly under new code. - tsc --noEmit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Recording (sonichi#1034) ax-read used kCGWindowName from CGWindowListCopyWindowInfo to read the frontmost window title. That specific call requires Screen Recording permission. Because ax-read is built via 'swift build' (ad-hoc signed, hash changes every rebuild), the TCC grant orphans on rebuild and macOS re-prompts the owner every time — and on Sequoia the grant doesn't appear to persist reliably across launches even within one binary. Stop reading kCGWindowName. The window title was already fetched as a fallback via kAXTitleAttribute — the code's own comment called it 'more reliable than kCGWindowName'. Make that AX read the sole source: it needs only Accessibility permission, which Sutando.app already holds and which is stable across rebuilds. Changes: - Remove windowName from FrontmostInfo struct - Remove kCGWindowName read in frontmostFromCGWindowList() - Remove the 'if windowTitle.isEmpty' guard on the AX block — runs unconditionally now (windowTitle is always empty before it) - Expand the MARK comment to document the intentional omission Smoke test on rebuilt binary returns {"app":"Discord","window_title":"@sutando - Discord",...} — title populates from AX, no Screen Recording prompt fires. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…ning to operator-private skill repo (sonichi#1013) The prior runbook mentioned the owner by name in three places ("which PR is X mid-merge on?", "did X just ask 'find me a flight' 10 minutes ago?", "Rules retired in PR sonichi#705 (per X 2026-05-14)"). Replaces those with generic "operator" / "owner" phrasing. Also adds a top-of-file note that per-fleet tuning rules (voice-friendly rationale shape, PR-merged demotion for #NNNN-referencing notification emails, active-thread boost, top-3 time-sensitive ordering) belong in the operator's private skill repo — they encode specific people, PR numbers, and event names that don't generalize. The algorithm shape (7 steps, cache schema, eviction lifecycle, voice-tool contract, failure modes) stays public; operators layer their tuning on top. Bumps iteration log to v2 with the change rationale. Pure docs — no behavior change, no consumer code touched. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… entry (sonichi#1060) Adds step 4 to the skill: after looping over `crons.json` entries, check whether any references `/proactive-loop`. If none does, schedule one with `*/10 * * * *` as a bootstrap-safety net. Closes a real silent-failure mode: post-sonichi#954 the CLI boots with `-- "/schedule-crons"` and the skill exits after confirming. If `crons.json` is missing/empty/forgot-to-include-the-loop-entry, the session goes idle with no recurring work driver — caught on Mac Mini this morning when proactive-loop was running session-only via a prior `/loop` invocation and would have died on next restart. Idempotent: existing custom-cadence entries (`*/5`, `*/15`) satisfy the check and the fallback skips. Operator who explicitly wants no proactive-loop can still set one with a far-future cadence, or strip the SKILL.md fallback locally. Per Chi's greenlight 2026-05-23T12:40Z reply to Mini's offer. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ref install steps (sonichi#1014) claude-router routes between `codex` and `gemini` for programmatic delegation from scripts/cron/bridges via `route-ai.sh`. With openai/codex-plugin-cc now an official Claude Code plugin, interactive review work from a Claude Code session should use `/codex:review` (and friends) directly — the router is the wrong surface for that case. Adds a "When NOT to Use" section between "Routing Rules" and "Default Behavior" that: - Points interactive review at `/codex:review`, `/codex:adversarial-review`, `/codex:rescue`, `/codex:status`. - Tells readers who don't see those slash-commands to install the plugin via the 4 commands in `skills/claude-codex/SKILL.md`'s "When NOT to Use" section (single source of truth for install steps; avoids duplication). No runtime change to `route-ai.sh` — its invocation contexts (script, cron, bridge) don't have access to slash-commands anyway, so plugin-presence detection would be dead code. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…art (sonichi#1056) * feat(catchup-after-startup): rebuild last-session context on fresh start New user-invocable skill `skills/catchup-after-startup/` — `/catchup-after-startup [hours]`, default 3h window. Reads everything Sutando persists across restarts and emits a single briefing into the new session's conversation buffer before the user types: session-state.md, open PRs by liususan091219, in-flight workspace/tasks, recent results, pending-questions filtered to un-resolved, last-N-hours voice/phone/ discord activity from data/conversation.sqlite (per-surface tables from sonichi#1051), recent chat from logs/conversation.log, recent git commits, build_log tail with staleness warning, health-check one-liner. Designed to be the FIRST action of any fresh proactive-loop session so the bot doesn't restart cold and forget what it was working on, what PRs are open, what channel a thread lives in. Recall half of sonichi#1032 (episodic event memory). The capture half (wiring event_log.py into every lifecycle point) remains a separate followup tracked under the same issue. scripts/install-hook.sh is an idempotent installer for a paired SessionStop hook in ~/.claude/settings.json. Without the hook, session-state.md is only written on PreCompact; a clean ⌘Q between compactions leaves the most-recent N-min window invisible to the next catchup. The skill still works without the hook (the other 9 sections are real-time persisted regardless) — installing closes the last gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address sonichi#1056 review: REPO default, pipefail, arg parsing, filter, auto-fire framing Combined fixes for @qingyun-wu's + Sutando-Mini's review comments on sonichi#1056. Both reviewers flagged the hardcoded personal REPO path as a blocker; the rest is polish + correctness. Script (skills/catchup-after-startup/scripts/catchup-after-startup.sh): - REPO default: env wins, else probe $HOME/Desktop/sutando, $HOME/Documents/sutando/sutando, $HOME/Documents/sutando, $HOME/sutando, $(pwd) for a real checkout (CLAUDE.md + skills/ + .git signature, same heuristic session-handoff.sh uses), else fall back to the conventional $HOME/Desktop/sutando. Auto-finds the repo without per-user env on any reasonable layout. (qingyun sonichi#1 + Mini sonichi#3) - Loud one-liner warns when REPO doesn't resolve to a real dir, so a misconfigured operator sees "REPO not found" instead of silently empty briefing sections. (qingyun sonichi#2) - $1 honored for the documented [hours] arg. Pre-fix, only CATCHUP_HOURS env was read; positional arg was silently ignored. (Mini sonichi#1) - set -o pipefail + restructure all piped commands to capture output into a variable + sentinel-on-failure, so a sqlite/git/python failure inside a pipe surfaces as "(failed: ...)" instead of silently empty output. The "(none)" vs "(failed)" vs "(missing)" distinction is the whole point of the empty-vs-failed contract. (Mini sonichi#2) - pending-questions resolution filter no longer over-matches bare 'DONE' / 'RESOLVED' substrings in body prose. Now requires an anchored marker — header containing ✅/Resolved/Dismissed/DONE, or a body line literally starting with ✅. (qingyun sonichi#4) SKILL.md: - Reframe "auto-fire" section. Pre-fix it implied auto-firing was built-in; in reality the slash command alone gives no auto-fire. Renamed to "Wiring for auto-invocation (operator-side, NOT in this PR)" with a sample markdown snippet operators can drop into their own proactive-loop SKILL.md. (Mini sonichi#4) - Explicit note that the sqlite section depends on sonichi#1051's per-surface schema; on a pre-sonichi#1051 db that section degrades to "(sqlite query failed)" while the other 9 sections still work. (qingyun sonichi#3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(catchup): jsonl-source recovery + install-hook probe + proactive-loop wiring (sonichi#1056) Three follow-up fixes on top of the address-review commit, addressing the last open blocker plus a real-incident fix plus the recovery mechanism that makes catchup work even when hooks fail. **1. Wire catchup into proactive-loop "On activation" step 1.** Addresses sonichi sonichi#4 — the SKILL.md claim "auto-fire as first action of fresh proactive-loop session" was not wired in code. Now: sentinel-guarded fire-once-per-fresh-session, skipped on cron-driven passes. **2. install-hook.sh resolves REPO at install time.** Real incident 2026-05-23 on Mac Studio: the old hook embedded \`\${SUTANDO_REPO_DIR:-\$HOME/Desktop/sutando}\` verbatim, so machines without the env set AND without a Desktop/sutando checkout silently no-op'd every SessionStop — session-state.md never got written. Catchup's primary source was therefore empty for weeks. Now the installer uses the same probe as catchup-after-startup.sh and bakes the literal path into the hook; misconfig fails loudly at install instead of silently at fire-time. **3. New section: "Previous-session transcript (last N h)".** session-state.md is a 50-line summary written by session-handoff.sh, which only fires when the hooks succeed. Both have failed in practice. The Claude Code project transcript .jsonl is always there (Claude writes it natively, no hooks) and is richer (full user/assistant turns vs a summary). When session-state.md is missing or stale, this is the source of truth for "what was happening last session". Reads the most-recent .jsonl in \$HOME/.claude/projects/<repo-slug>/ that's been untouched for >1min (skips the live session's file). Filters out tool-result envelopes, system reminders, interrupt markers. Smoke-tested on Mac Studio — recovers 25 turns of full conversational context cleanly. * fix(session-handoff): clear proactive-loop sentinel so next session auto-fires catchup (sonichi#1056) Paired with `skills/proactive-loop/SKILL.md` step 1's sentinel guard. Without this, the `proactive-loop-started.sentinel` from the just-ended session persists across restart — next session's first `/proactive-loop` sees the sentinel, skips `/catchup-after-startup`, and the auto-fire wiring is silently defeated for every restart after the first. One-line fix: SessionStop hook → session-handoff.sh now `rm -f`s the sentinel after writing session-state.md. Idempotent (no-op if sentinel absent). * feat(schedule-crons): fire /catchup-after-startup on fresh session (sonichi#1056) Wire catchup at the schedule-crons bootstrap entry so it runs synchronously at session start, not 5 min later when the first main-loop cron fires /proactive-loop. Post-sonichi#954 (Chi), start-cli.sh boots with `-- "/schedule-crons"`, making this skill the actual startup entry. With `822e630` of this PR catchup is wired into /proactive-loop step 1 — but that path only fires after the main-loop cron first triggers (~5 min lag, longer if the cron list grew the loop entry above */5). Wiring catchup here as well lets the same sentinel-guarded fresh-session check trigger immediately on bootstrap. Whichever path runs first touches the sentinel; the other skips. Idempotent. Defensive guard: if the `catchup-after-startup` skill dir isn't installed yet (pre-merge of this PR on a fresh checkout), step 0 silently skips. No hard dependency on this PR landing first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…loop (sonichi#1049 follow-up) (sonichi#1066) * fix(task-bridge): bumper in-place write — avoid fswatch self-trigger loop (sonichi#1049 follow-up) Per @qingyun-wu and @liususan091219 PR sonichi#1049 reviews — real bug they caught after merge. `task_bump_attempts.py` was using `tmp + tmp.replace(file_path)` for crash-atomic writes, but the rename onto `tasks/<name>.txt` fires fswatch's **Renamed** event. `watch-tasks-stream.sh` subscribes to `--event Created --event Renamed`, so the bumper's own rename re-triggers the watcher's emit-loop → bumper runs again → another rename → another emit → infinite self-trigger loop. The fork's `.mjs` watcher has a per-process `seen` Set that bounds the loop to a single extra bump (`seen.has(filename) → return`), so fork PR sonichi#63 escapes the worst of it. The upstream `.sh` watcher has no such guard — every Renamed event runs the bumper. That's the catastrophic case @qingyun-wu reported ("attempts inflates AND re-emits until the agent claims"). ## Fix Replace tmp+rename with in-place write: - Before: `tmp.write_text(...) ; tmp.replace(file_path)` - After: `open(file_path, "w") as fh: fh.write(updated)` In-place open+write fires fswatch's `Updated`/`Modified` event, which the watcher is NOT subscribed to (`--event Created --event Renamed` only). Self-trigger loop closed. ## The trade We lose strict crash-atomicity (a crash MID-WRITE could leave the file half-written). Acceptable because: 1. The bumper is tiny + fast — the write window is microseconds. 2. Task-file parsers fail closed on malformed files (no `task:` line → `_parse_task_file` returns None, no claim). Half-written files are skipped, not silently mis-processed. 3. Bridge restart re-runs the bumper before re-emit, so a corrupted file gets one chance to be re-written cleanly. The avoided bug (infinite self-trigger loop, files re-emit and re-bump forever) is much worse than the introduced risk (microsecond-window crash leaves a malformed file). ## Tests `tests/task-bump-attempts.test.py` — 10 cases (up from 9): - Updated `test_no_tmp_file_left` to call out the architectural reason (no .tmp because no tmp+rename, not because cleanup). - NEW `test_bumper_uses_in_place_write_not_rename` — source-grep forbids `.replace(file_path)` and `os.rename(...file_path...)`; requires `open(file_path, 'w'`. Pinned so a future refactor that "improves atomicity" by reverting can't slip past review. All 10 pass locally. Co-Authored-By: qingyun-wu, liususan091219 (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> * docs(task-bridge): tighten bumper rationale per @qingyun-wu review The docstring's justification (3) — "Bridge restart re-runs the bumper before re-emit, so a corrupted file gets one chance to be re-written" — didn't actually hold. `open(w)` truncates the file FIRST, so a crash mid-write leaves the file empty and the original content is GONE. Re-running `bump_attempts` on the now-empty file hits the no-`task:`- line path and returns 0 without restoring anything. The task is lost. The trade-off is still worth it (definite self-trigger loop is much worse than a microsecond-window data-loss risk), but the comment shouldn't oversell safety. Rewrote (3) to drop the "one chance to be re-written" claim and explicitly call out that crash → task lost (not "task delayed"). The two honest mitigations remain: (1) tiny write window, (2) fail-closed parsing means lost-not-misprocessed. Also noted that watcher-side dedup (the .mjs `seen` set on the fork) is the path back to atomicity if stronger durability is wanted — separate PR. Added a one-line mention of Lucy's production repro (`attempts=1022` in 60s) so future readers see the storm magnitude. Code unchanged — comment-only. Co-Authored-By: qingyun-wu (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> --------- Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
sonichi#1070) task_bump_attempts.py uses atomic rename (tmp.replace(file_path)) which re-triggers fswatch's --event Renamed filter, causing infinite re-emission: emit → bump → rename → fswatch event → emit → bump → ... This killed the Monitor (output rate limit) after processing just one Telegram task, requiring a manual restart. Add a 3-second per-basename cooldown using $SECONDS to suppress re-emission of the same file within the dedup window. The cooldown is long enough to absorb the rename event but short enough that a genuinely new task with the same basename (after archive+recreate) still gets picked up. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…onichi#1069) 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: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#1068) dashboard.py, slack-bridge.py, and tests/task-priority.test.py used Python 3.10+ X | None union syntax without the PEP-563 guard, causing TypeErrors on system Python 3.9.6 (Xcode toolchain). telegram-bridge.py was already fixed upstream. Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…flake commits section (sonichi#1076) The "Recent commits" section never populated in a submodule or worktree checkout, where `.git` is a file (gitdir pointer) rather than a directory: - The repo auto-detect probe and the commits-section gate both tested `-d "$REPO/.git"`, rejecting such checkouts even when git works fine. Use `-e` so a `.git` file or dir both pass. - Once past the gate, `git log ... | head -25` under `set -o pipefail` intermittently reported "(git failed)": head closes the pipe after 25 lines, SIGPIPEs git (exit 141), and pipefail surfaces it whenever the window holds >25 commits. Capture git's output first (its real exit governs the failure branch), then trim with head on a plain string. Verified on a submodule checkout (sutando pinned inside a private super-repo): the commits section now populates reliably (8/8 runs) and auto-detect finds the repo when SUTANDO_REPO_DIR is unset. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ootstrap (sonichi#1072) * feat(startup): canonical single-entry-point skill for fresh-session bootstrap Per Chi 2026-05-23 Discord redesign discussion ("we can make a new skill and include everything we need at start"). Wraps the sequence: 1. /catchup-after-startup (briefing, sonichi#1056) 2. /task-orphan-check (orphan recovery, optional/sentinel-guarded) 3. /schedule-crons (cron registration + watcher start) 4. touch sentinel 5. emit summary into a thin orchestrator. Each sub-skill stays callable standalone for manual re-invocation; /startup just enforces the order. Background: post-sonichi#954 the CLI startup target became /schedule-crons, which over time accumulated startup ceremony (step 0 catchup from sonichi#1056, step 4 cron fallback, step 5 watcher start) on top of its actual job (reading crons.json). The launchd plist + CLI scripts pointing at /schedule-crons made it the de-facto entry without ever being declared as such. /startup makes that explicit and gives future startup work an unambiguous home. This PR ships only the skill itself. Migration of the launchd plist / CLI invocation scripts to call /startup instead of /schedule-crons is a separate follow-up (operator-side, no code change in this repo). The /task-orphan-check reference is sentinel-guarded — /startup works fine without it; the orphan-check skill is the recovery half of the sonichi#1049 redesign and ships in its own PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(startup): move sentinel touch to step 1b — closes double-catchup bug Per qingyun-sutando review on sonichi#1072 (2026-05-23 22:18Z): the original sentinel touch lived at step 4 (the end), but step 3 (/schedule-crons) re-checks the sentinel at its own step 0 and re-runs /catchup-after-startup if absent. Net effect: every fresh /startup → catchup fires twice (once at step 1, once again inside /schedule-crons because the sentinel hadn't been touched yet). Fix: move the touch to a new step 1b, immediately after step 1's catchup and BEFORE step 2 / step 3. Both /task-orphan-check (step 2) and /schedule-crons (step 3) now see the touched-sentinel state — symmetric. Renumbered "confirm" from step 5 to step 4. Sequence diagram updated. No behavior change beyond the ordering. Operator-visible difference is that the briefing prints once instead of twice on every fresh CLI start. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ask-orphan-check in sonichi#1074) (sonichi#1075) * Revert "fix(task-bridge): bumper in-place write — close fswatch self-trigger loop (sonichi#1049 follow-up) (sonichi#1066)" This reverts commit 31b908b. * Revert "feat(task-bridge): attempts counter on task files (restart-safety) (sonichi#1049)" This reverts commit 146aeb8.
…onichi#1049 attempts-counter) (sonichi#1074) * feat(task-orphan-check): startup-time orphan-task recovery (alternative to sonichi#1049/sonichi#1066) Per Chi 2026-05-23 Discord redesign exchange. Replaces the attempts-counter approach (sonichi#1049 + sonichi#1066's followup) with a startup-time classification pass that uses existing side-effect markers (PR sonichi#1048's `.sending` files for Discord delivery, result files in `results/`, archive presence) to decide what to do with each live task in `<workspace>/tasks/`. ## The bug being addressed If the agent crashes mid-task with non-idempotent side effects already executed (Discord message sent, file written, API call made) but the archive of result + task files never ran, on restart the task file is still in `tasks/`. The watcher re-emits. The agent re-processes. The side effect fires again. ## What sonichi#1049 tried Inline attempts-counter inside the task file. Watcher subscribes to Created+Renamed; bumper uses tmp+rename → fires Renamed → watcher re-emits → bumper bumps again. Infinite loop. ## What sonichi#1066 tried Switch tmp+rename to in-place `open(file, 'w')`. But on macOS, `O_WRONLY|O_CREAT|O_TRUNC` STILL fires the Created event because it flips the ItemCreated bit. Verified empirically 3x. Doesn't close the loop. ## What this PR does Move the dedup logic out of the watcher's event surface entirely. On startup (invoked as step 2 of /startup from PR sonichi#1072), the agent does ONE classification pass: - For each tasks/task-<id>.txt: - If results/task-<id>.txt OR results/archive/task-<id>.txt OR results/proactive-<id>.txt[.sending] exists → DONE, archive - Else if task arrived <5min ago → FRESH, leave for watcher - Else → ORPHAN, write recovery-sentinel result + archive task No counter inside the task file, no in-band writes, no bumper, no self-trigger loop. The watcher runs unchanged; just sees a smaller tasks/ dir. ## What this skill ships - skills/task-orphan-check/SKILL.md (~170 lines, mostly procedure prose) No bash/python script — the classification rules are LLM-judgment territory (cross-reference markers, age-relative-to-now, 3-outcome decision). A bash mirror would be a duplicate code path. Can promote later if workload grows. ## Followup (separate PRs) - Revert sonichi#1049: drop task_bump_attempts.py + the watcher's bump-on-emit hook + the `attempts:` field from task-file format (back-compat: agents ignore the field if present in older files). - Close sonichi#1066 as "superseded by /task-orphan-check." - Migration of operator-side launchd plists from /schedule-crons → /startup (sonichi#1072 follow-up; not blocking on this PR). ## Test plan - [ ] Fresh workspace (no tasks/) → orphan-check emits "no tasks/ dir, nothing to recover" - [ ] tasks/ with no live tasks → "no live tasks, nothing to recover" - [ ] Live task <5min old → classified FRESH, untouched - [ ] Task with results/task-<id>.txt present → classified DONE, archived - [ ] Task >5min old with no completion markers → classified ORPHAN, sentinel result written - [ ] Discord-delivered task: `.sending` lifecycle from sonichi#1048 read correctly Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(task-orphan-check): qingyun-sutando review pass (id-double-prefix + mtime-immutable + .sending contract + residual-hole) 5 corrections from qingyun-sutando's "approve with changes" review on sonichi#1074: 1. Fixed `<id>` path-template ambiguity. The task header is `id: task-<epoch-ms>` (already includes the `task-` prefix). Original wording used `results/task-<id>.txt` which would resolve to `results/task-task-<epoch>.txt` — double-prefixed. Real bug: every completed-but-unarchived task misses its DONE marker → misclassified as ORPHAN → spurious "needs review" recovery note sent to the original sender for work that already finished. That inverts the skill's purpose. Now uses `<id>` consistently across step 2/3 with an explicit note. 2. Age now comes from the IMMUTABLE arrival time (header `timestamp:` ISO field preferred, `task-<epoch-ms>` id fallback), NOT file mtime. Mtime gets reset by `sync-workspace.sh`, `git checkout`, any `touch`, etc. — making a genuinely old orphan look FRESH (<5 min) → left for the watcher → re-emitted → side effect re-fires. That's the exact bug this skill exists to prevent. 3. Added step 2b clarifying the `.sending` contract: `<id>.txt` (no suffix) = DONE (task processed + result body written), `<id>.txt.sending` = bridge mid-delivery (sonichi#1046/sonichi#1048 lifecycle). Treat .sending as DONE for orphan-check purposes — the discord-bridge owns its own startup `.sending` sweep, so we don't second-guess. 4. Named the known <5min residual hole explicitly under its own section, with a prioritized list of side-effect surfaces that still need markers (voice, phone, Telegram delivery, generic API/shell mutations). Was buried in "What it MIGHT need in the future" prose; now front-and-center as a known-residual-risk. 5. Noted the "promote to scripts/orphan-check.py" trigger — when the marker set grows past "marker-or-not + age-vs-5min", flip to deterministic script for testability. All 5 are in qingyun-sutando's review comment on sonichi#1074. Items 1 and 2 are real correctness bugs (would have produced wrong outcomes); items 3-5 are clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er fix, follow-up to sonichi#1063) (sonichi#1065) * chore(watch-tasks-stream): PID file + Stop-hook cleanup (orphan-watcher fix, follow-up to sonichi#1063) When a Claude Code session ends, the streaming task watcher started via the `Monitor` tool can outlive it as an orphan fswatch process — diagnosed via PR sonichi#1063 (heartbeat) which Chi closed in favor of an explicit Stop-hook cleanup path. This is option A from that discussion (option B = fleet install-hooks.sh ships separately). Changes: - `src/watch-tasks-stream.sh`: write own PID to `$WORKSPACE/state/watch-tasks-stream.pid` at startup; `trap EXIT` removes it on clean shutdown (SIGTERM/SIGINT/normal exit). - `src/startup.sh`: reap stale PID file from a prior hard crash before the new session starts. Cmdline check (`ps -p $PID -o args= | grep watch-tasks-stream`) before kill so a recycled PID held by an unrelated process is never killed; stale file is removed unconditionally. Companion settings.json edit (NOT in this PR — `.claude/` is gitignored, so each fleet host applies it locally; option B install-hooks.sh will automate fleet-wide): add a second Stop hook command alongside `check-pending-tasks.sh`: PID_FILE="${SUTANDO_WORKSPACE:-$HOME/.sutando/workspace}/state/watch-tasks-stream.pid" if [ -f "$PID_FILE" ]; then PID=$(cat "$PID_FILE" 2>/dev/null) [ -n "$PID" ] && kill "$PID" 2>/dev/null rm -f "$PID_FILE" fi exit 0 Already applied locally on this MacBook for testing. Three exit paths covered: 1. Clean Claude Stop -> Stop hook kills + removes PID file. 2. SIGTERM/SIGINT -> EXIT trap inside watcher removes PID file. 3. Hard crash (SIGKILL, panic, force-quit, power loss) -> next startup.sh run reaps the orphan (if still alive + cmdline matches) and clears the stale PID file. Verified locally with lifecycle tests covering all three cases (clean SIGTERM cleanup, stale-PID reap with defunct PID, recycled-PID skip via cmdline check). Per Chi 2026-05-23 — branch + PR (no merge); Chi reviews before merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(watch-tasks-stream): per-PR install-hook installer Adds src/install-watch-tasks-cleanup-hook.sh — idempotent installer for the new Stop hook that pairs with watch-tasks-stream.sh PID-file cleanup. Per Chi 2026-05-23 (DM thread): mirrors Lucy's per-PR pattern in sonichi#1056 (`install-hook.sh` for SessionStop → session-handoff.sh). Each PR that adds a new hook ships its own targeted installer; the fleet-wide install-hooks.sh is deferred (option 3 from the A/B/C choice). Per `feedback_claude_code_hook_scoping` (memory): sutando hooks belong at project-level `.claude/settings.json` (gitignored, per-machine), NOT user-level `~/.claude/settings.json` — they only fire when Claude runs in the sutando project context, not in unrelated sessions. Tested: - fresh install on empty `{}` → adds 1 hook entry under hooks.Stop[0] - re-run on installed → no-op, settings unchanged - detect-by-command-string match prevents duplicate insertion - atomic via jq + tmp+mv Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(install-claude-hooks): expand to cover PreCompact + check-pending-tasks Renames src/install-watch-tasks-cleanup-hook.sh → src/install-claude-hooks.sh and extends to install all 4 sutando-owned project-level hooks idempotently: PreCompact → cp $TRANSCRIPT_PATH ~/Desktop/sutando-conversations/... PreCompact → bash src/session-handoff.sh "$TRANSCRIPT_PATH" Stop → bash src/check-pending-tasks.sh Stop → watcher-cleanup PID kill (sonichi#1065's contribution) Per Chi 2026-05-23 17:49Z: the previous installer (just watcher-cleanup) left 3 hooks installerless. A fresh Mac would have been missing transcript backup, PreCompact session-handoff, and pending-tasks check after sonichi#1065 + sonichi#1056 landed. This consolidates them into one place — but stays per-PR scope (doesn't grow into a fleet `install-hooks.sh`). Note: Lucy's sonichi#1056 installer for SessionStop → session-handoff.sh remains independent (different event); the two installers are orthogonal and both should be run on a fresh Mac. Tested all 3 paths: - fresh install on empty `{}` → added=4, skipped=0 - re-run after install → added=0, skipped=4 - re-run on existing settings.json → added=0, skipped=4 (no churn) Per-event command-string detection prevents duplicates; jq + tmp+mv keeps the edit atomic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#1077) * feat(discord-bridge): include channel_name + guild_name in task file Task files currently carry numeric `channel_id` only. The task-consumer (core agent reading from `tasks/`) has no human-readable label for the originating channel, so distinguishing one team channel from another requires grepping numeric IDs against a separately-maintained channel-reference memory file. That's fragile — IDs change, the memory file goes stale, and cross-team channels regularly route messages to the wrong bot's queue without the operator noticing. Add two purely-informational lines to the task body, both with safe defaults for DM channels: channel_name: <discord channel name> # or "DM" if no .name attr guild_name: <discord guild name> # or "DM" if not in a guild Additive change — existing task-consumers parse line-by-line k:v and ignore unknown keys, so backward-compat. Proposed by the operator after a real misroute incident. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): newline-sanitize channel/guild names Per qingyun review on sonichi#1077: Discord channel/guild names rarely contain newlines, but if one ever did, the un-sanitized value would inject a spurious field line into the task file's k:v shape (corrupting the metadata header). One-liner .replace('\n', ' ') on both makes it bulletproof. Also tightened the comment to match the leak-scrubbed PR body (generic 'one team channel from another' instead of fleet names). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…board shows it (sonichi#1768) The credential-proxy (skills/quota-tracker/scripts/credential-proxy.ts) is the OAuth-injection + quota-header path, but health-check.py never probed it — so the dashboard Services panel (which renders health-check entries whose detail contains "port"/"running") never listed it. A dead proxy = broken auth/quota for every proxy-routed core, with no dashboard signal. Add a `check_port(7846, "credential-proxy", probe=False)` to run_all_checks. Plain TCP-listening check (not the HTTP liveness probe): the proxy forwards everything upstream and has no liveness endpoint, so an HTTP probe would be forwarded to api.anthropic.com and misread as "wedged". Optional service (not every node routes through it) → down is downgraded to a warning, matching agent-api/dashboard/etc. Verified: `credential-proxy ok port 7846` now appears, and the "port 7846" detail passes the dashboard Services filter. py_compile clean. Stand: Echo Act IV Pro
…nse (sonichi#1695) The poll loop only backed off inside the `except` arm. When `api()` collapses an HTTPError into `{"ok": False}` — which it does for a 429 rate-limit, a 5xx, or an auth error — the loop fell straight through and re-polled getUpdates with NO delay. On a 429 that is actively harmful: hammering the endpoint during a rate-limit extends Telegram's `retry_after` penalty. The live bridge log shows the symptom — repeated `API error 429: Too Many Requests: retry after 5` back-to-back. Add a 5s backoff on any non-ok result before the next poll, mirroring the existing exception-arm backoff. Minimal surgical diff: the following `if result.get("ok")` is now always true but left un-refactored to avoid re-indenting the ~70-line message handler. Tests: 6/6 telegram-bridge tests pass; py_compile clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
… manual scroll-up (sonichi#1714) Every streaming update unconditionally set scrollTop = scrollHeight at 8 call sites, so scrolling up to read history was impossible while the agent was talking. Replace with scrollTranscript(): a scroll listener tracks whether the user is pinned to the bottom (within 40px), and autoscroll only fires while pinned. Scrolling back to the bottom re-pins. The user's own typed message forces the jump (force=true). Stand: Echo Act IV Pro Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…eak secrets (sonichi#1762) 2026-06-23 incident: an owner `vault set TELEGRAM_BOT_TOKEN <token>` crashed the message handler — intercept_vault_commands → secret_scanner → detect_secrets, which wasn't installed on that node → uncaught ModuleNotFoundError at L2939 — so the token wasn't stored, AND it leaked to discord-bridge.log because the raw message text was logged at L2866 *before* the intercept ran. - Wrap intercept_vault_commands in try/except: on ANY failure fall back to redact_vault_commands, so the raw value never reaches the task file/downstream (just not stored to Keychain) and the handler keeps running. Defense for a missing optional dep or any intercept bug. - Redact the @user message log print (L2866) so a `vault set` secret never lands in the log in plaintext, regardless of intercept outcome. The underlying missing dep (detect-secrets — declared in requirements.txt but not installed on that node) is a separate provisioning fix (`pip install -r requirements.txt`). This PR makes the path safe even when a dep is missing. Stand: Echo Act IV Pro
…-when-missing (self-documenting) + drop requirements.txt (sonichi#1764) * fix(vault): degrade gracefully when detect-secrets soft dep is missing vault-set's false-positive guard imports `secret_scanner` (→ detect-secrets) to validate unquoted values. When the dep isn't installed the import raised ModuleNotFoundError, which the caller (sonichi#1762) catches by falling back to redact-without-store — so an explicit owner `vault set` silently dropped the secret instead of storing it. Make detect-secrets a true soft dependency: wrap the lazy import in try/except ImportError. On a missing dep, store the value anyway (an explicit owner vault-set is overwhelmingly intended as a secret) and emit ONE actionable log line so the agent sees it and can `pip install detect-secrets` to re-enable prose-filtering. Quoted values already bypass the guard, so they were never affected. Behavior verified on a node without detect-secrets: unquoted `vault set K V` now stores + warns instead of crashing; the with-dep path (else branch) is unchanged. Follow-up to sonichi#1762. Stand: Echo Act IV Pro * chore(ci): drop requirements.txt — inline the one dev-only dep install requirements.txt held a single line, `detect-secrets>=1.5.0`, referenced only by CI. After the soft-import in this PR, detect-secrets is no longer a runtime dependency — it's a TEST/dev dep used solely by tests/secret-scanner.test.py. A whole requirements.txt for one dev-only package is pure indirection: CI needs the *dependency*, not the *file*. Inline it as `pip install 'detect-secrets>=1.5.0'` and delete the file. No runtime impact (soft-import), no test impact (the test still gets the dep, just installed directly). Stand: Echo Act IV Pro * fix(vault): refuse unquoted vault-set when detect-secrets is missing (was: store-anyway) The prior commit degraded a missing detect-secrets to "store the value anyway". That's unsafe: the vault-set regex is deliberately loose (matches `vault set K V` anywhere, including mid-prose) and delegates false-positive rejection to detect-secrets. Storing without that backstop means every mid-prose "vault set X Y" mention — including the owner merely discussing vault — gets stored as a junk Keychain entry and the message text redacted. That reintroduces exactly the FP problem detect-secrets was added (sonichi#1354) to prevent. Fail safe instead: an unquoted value on a host without detect-secrets is REFUSED (not stored) and replaced with a `[VAULT-NEEDS-DETECT-SECRETS]` placeholder. The core agent recognizes that placeholder (documented in CLAUDE.md "Vault"), installs the dep, and asks the owner to re-send. No bridge-side pip-install (env mutation in a message handler) and no separate task or dedup sentinel: the refusal rides the normal task to core with channel context intact, the install is idempotent, and the refusal path stops firing the moment the dep is present (self-limiting). Quoted values (`vault set KEY "value"`) bypass the scanner and store even without the dep — unaffected, and the immediate workaround. Verified on a host without detect-secrets: - unquoted secret -> refused, nothing stored, placeholder emitted - mid-prose match -> refused, no junk Keychain entry (the FP case) - quoted value -> stored normally Stand: Echo Act IV Pro * docs(vault): trim the placeholder note to a minimal trigger The first version was a 7-sentence paragraph. CLAUDE.md is loaded into every session's context, and the project's own convention (the adjacent built-in-tools line: "Kept out of CLAUDE.md to save per-session context budget") is to keep niche detail out. Cut to the minimal trigger the agent needs — placeholder meaning + the action — and point the rationale at the code comment that already explains it. Stand: Echo Act IV Pro * refactor(vault): self-documenting refusal placeholder; drop CLAUDE.md entry Move the refusal instruction from always-loaded CLAUDE.md into the placeholder string itself. The placeholder lands in the task body the agent reads, so baking the action into it (run pip install detect-secrets, ask the owner to re-send, never echo the value) co-locates the instruction with the event it describes. Net: zero permanent per-session context cost (the text only appears on the rare refusal, not in every session's CLAUDE.md), no extra bridge code, and the instruction is impossible to miss because it's right there in the task. Reverts the CLAUDE.md "Vault" addition from earlier in this PR — CLAUDE.md is now untouched. Verified on a host without detect-secrets: unquoted refusal emits the self- documenting placeholder, no secret leaked; quoted values still store. Stand: Echo Act IV Pro
…) + legacy read-fallback (sonichi#1766) * fix(vault): move key-name manifest into <workspace>/state (non-synced) with legacy read-fallback The vault manifest (the NON-SECRET index of key names; values live in macOS Keychain) lived at `~/.sutando-secret-vault/keys.json` — a home dotdir, the exact anti-pattern the workspace contract exists to eliminate. Move it under the workspace at `<workspace>/state/secret-vault/keys.json`. It MUST stay per-host and non-synced because it indexes the per-host Keychain: if it synced, every node would inherit key names its local Keychain can't resolve — a lying index. `state/` is outside the sync carrier set (whitelist mode un-ignores only notes/ + hosts/<host>/ + memory/), so the new path is non-synced by construction — verified against scripts/sync-workspace.sh's carrier set. - New `_manifest_path()` resolves the canonical path via `resolve_workspace()`, falling back to the legacy home-dir path if the workspace can't be resolved. - New `_read_manifest()` prefers the canonical path, falls back to the legacy path for existing installs (one-line stderr deprecation note). - First write self-migrates: `_register_key` reads via `_read_manifest` (inheriting any legacy keys) then writes the merged set to the canonical path — no key-name loss, no manual migration step. The legacy file is left in place (harmless). Secrets are unaffected — they remain in Keychain, found by `security find-generic-password` regardless of where the index lives. Verified: canonical resolves under workspace/state/secret-vault; pre-move list reads legacy; first register merges legacy {A,B} + new key → canonical {A,B,...}; list then reads canonical. On a real install with no canonical yet, list_vault_keys() still returns the existing keys via legacy fallback (no write, no disruption). py_compile OK. Addresses the owner's "should the manifest be in the workspace / under CLAUDE_CONFIG_DIR?" thread. Stand: Echo Act IV Pro * test(vault): update vault-skill tests for the manifest-path refactor The manifest move replaced the module-level `_MANIFEST_PATH` constant with `_manifest_path()` (canonical) + `_LEGACY_MANIFEST_PATH` + `_read_manifest()`. The tests patched the removed `_MANIFEST_PATH`, breaking `tsc + tests (clean install)`. Update them to patch the new seams, and stop relying on a global `open` mock (the new `_manifest_path()` calls `resolve_workspace()`, which opens config files — a global mock would clash). - TestListVaultKeys: patch `_read_manifest` (list just sorts its keys). - New TestReadManifest: missing/corrupt → {}, canonical preferred over legacy, legacy fallback — the read-error + fallback logic that moved into _read_manifest. - TestRegisterKey: patch `_manifest_path` + `_LEGACY_MANIFEST_PATH`; add test_first_write_migrates_legacy_keys (canonical absent + legacy present → first write inherits legacy keys and creates the nested canonical dir). 20 tests pass locally (`python3 tests/vault-skill.test.py`). Stand: Echo Act IV Pro
sonichi#1772) sonichi#1720 removed the discord-voice consumer and its `discordVoiceKey` / `discord_voice_key` result-channel-key constructor, and CLAUDE.md was updated to match — but AGENTS.md's "Per-channel pull namespace" section still documented the dead constructor, the `dvoice-` prefix, and the deleted `skills/discord-voice/scripts/discord-voice-server.ts` scan path. A live node followed that stale doc and wrote an orphaned `results/dvoice-….txt` grounding file that nothing claims post-sonichi#1720 (caught by Maddy 2026-06-24). Bring AGENTS.md in line with the already-scrubbed CLAUDE.md: drop the discord-voice bullet, the discordVoiceKey/discord_voice_key constructor names, and the discord-voice-server.ts scan reference. phone- / phoneCallKey is now the only documented pull-side consumer. Stand: Echo Act IV Pro
…hi#1703) * test(result-channel-key): shared cross-language parity fixture One input/output table (tests/fixtures/result-channel-key.parity.json) that BOTH the TS test (result-channel-key.test.ts) and the Python test (result-channel-key.test.py) load and assert against — so the twin impls src/result-channel-key.ts and src/result_channel_key.py can never silently drift. Covers all six helpers: sanitizeKey, resultFilename, parseResultFilename, resultBelongsTo, discordVoiceKey, phoneCallKey. Adversarially validated: flipping one expected output fails BOTH languages' parity test (not a no-op loop). Closes the fixture gap Lucy flagged on sonichi#1595. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(result-channel-key): drop deleted discordVoiceKey from parity fixture discord-voice was removed in sonichi#1720, taking the discordVoiceKey / discord_voice_key constructor with it. This PR (opened before sonichi#1720) still pinned that constructor in the parity fixture + the TS/PY tests, so after update-branch the `tsc + tests` job failed with `discordVoiceKey is not defined` (tests/result-channel-key.test.ts:276). Remove the dead `discordVoiceKey` fixture array and its TS/PY assertions. The generic-helper cases that happened to use `dvoice-` as an arbitrary channel key are rebased onto the live `phone-` prefix rather than deleted, so resultFilename / parseResultFilename / resultBelongsTo keep their full edge-case coverage (match / mismatch / temp-suffix / non-task / flat-form). Stand: Echo Act IV Pro --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e gets tokens (sonichi#1775) The Electron backend-supervisor gates the Slack daemon on the presence of ~/.claude/channels/slack/.env but builds the child env from process.env + workspace .env only — it never injects the channel .env into the child. The discord and telegram bridges self-load their channel .env, so they survive this; the Slack bridge did not. Net effect: a token entered via the app's Settings page (which persists to channels/slack/.env) never reached the bridge. slack-bridge.py exited immediately with "SLACK_BOT_TOKEN and/or SLACK_APP_TOKEN not set" and the supervisor restart/backoff loop turned that into a crash-loop. Fall back to reading the channel .env when the tokens aren't already in the environment, mirroring discord-bridge.py. Fixes the bridge under every launch path (Electron supervisor, startup.sh, manual, launchd). Co-authored-by: jsun-m <jsun-m@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or discord/telegram/slack (sonichi#1746) * feat(bridges): emit channel.<surface>.<in|out> observability events Wire the discord, telegram, and slack chat bridges into the observability spine (src/observability, sonichi#1671) so every accepted inbound message and delivered reply lands one `channel.<surface>.<in|out>` event in the local events JSONL the collector + dashboard read. Reference taxonomy comes from the stando bridges' cloud-metrics `channel.discord.<in|out>` / `channel.telegram.<in|out>` accounting; here it is routed through the envelope-based `emit()` instead, and Slack — which had no channel events anywhere — is added. - New `src/observability/channel.py`: single-source `emit_channel(surface, direction, ...)` builds the ObsEvent envelope (source, actor, kind, data). One constructor owns the taxonomy + field mapping, mirroring the result_channel_key discipline. Best-effort; never raises into a bridge. - Each bridge gets a guarded import (no-ops if the module is missing) plus an inbound emit at task-write and an outbound emit at reply-send. `data` carries task_id, is_dm/access_tier, and surface-specific counts. The dashboard Kind list is data-derived, so `channel.slack.in/out` appears automatically once emitted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bridges): thread access_tier to outbound channel events + add test Addresses review nits on PR sonichi#1746. Nit 1 — outbound tier skew. The `.out` events labelled actor.access_tier "owner" for non-owner replies: - discord: pending_task_tiers was popped (cleanup) BEFORE the .out emit, so the .get(task_id, "owner") always read "owner". Capture the tier into a local right at the pop and use it for the emit. - slack: _send_reply had no tier in scope. Stash access_tier into the pending_replies[task_id] entry at inbound and thread it through a new _send_reply(access_tier=...) param to the .out emit. - telegram: unchanged — owner-only (allowlist-gated), so "owner" is accurate. Nit 2 — add tests/observability/channel.test.py: locks kind/source mapping, actor shape, str-coercion, data merge + channel_id setdefault, trace_id passthrough, and the best-effort (never-raises) contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bridges): never default unknown access_tier to "owner" in obs events A missing/lost tier was falling back to "owner" (discord `.out` pop-miss, slack `_send_reply` default + main-delivery `.get` default, helper default). That silently upgrades a non-owner reply to owner in per-tier channel accounting — the exact skew this work is meant to remove. Fail-safe sentinel "unknown" everywhere we'd otherwise be guessing: - emit_channel() default access_tier → "unknown" - discord `.out`: `pop(...) or "unknown"` - slack `_send_reply` default + main-delivery `.get(..., "unknown")` "owner" is now only ever set where it is actually known: - telegram (allowlist-gated owner-only) — passed explicitly on `.out` - slack proactive owner-DM — passed explicitly Adds a test asserting the default tier is "unknown", not "owner". * fix(telegram-bridge): source obs access_tier from tier map, don't assert "owner" Follow-up to the same fail-safe principle: telegram was the last place still hardcoding access_tier="owner" literally in the channel obs events. Telegram is allowlist-gated owner-only today (every accepted task gets pending_task_tiers[task_id]="owner"; no per-sender tier classification), so the value is correct — but asserting it as a literal would silently lie if that model ever changes, and the .out literal was effectively a default. - send_reply() gains an access_tier param (default "unknown"). - inbound .in sources access_tier=pending_task_tiers[task_id] (single source of truth, set one line above). - task-reply .out sources pending_task_tiers.get(task_id, "unknown") at the call site — reads the real tier (still present; _clear_progress pops only after the send), fail-safe "unknown" on lost/post-restart state, never "owner". - proactive owner-DM passes access_tier="owner" explicitly (genuinely owner). No remaining fail-open-to-owner default in any bridge's obs path. * fix(telegram-bridge): count externally-sent attachments in channel.telegram.out The task-reply path calls send_reply(parsed.body, ...) with file markers already stripped and sends attachments separately from parsed.actions, so send_reply's internal `files` was always empty on that path. Result: file-only replies emitted NO outbound event (clean_text and files both empty), and text+attachment replies reported file_count=0. Thread the externally-sent attachment count through a new extra_files param; the .out emit now fires on `clean_text or total_files` and reports len(files)+extra_files. Proactive path (raw text, markers handled inside send_reply) is unaffected — extra_files defaults to 0. * fix(obs): keep channel events in-schema + reflect real delivery outcome Two correctness fixes for the channel.<surface>.<in|out> events. 1. Schema conformance. The bridges emit access tiers "other" (pre-existing) and "unknown" (fail-safe sentinel), but events.ts AccessTier only allowed owner|team|public — out-of-schema for any TS consumer/validator. Add "unknown" to the AccessTier union (events.ts + events.py) and normalize the bridge vocabulary onto the schema at the single emit boundary in channel.py: other→public (the schema's non-owner-external value, matching realtime-map), anything unrecognized→unknown (never owner). 2. Delivery overcount. slack/telegram emitted .out as outcome="ok" even when the send failed: api()/send_file()/chat_postMessage swallow errors, and the emit ran unconditionally (telegram's even fired before the external attachment loop). Now: - slack _send_reply tracks delivered_ok across text chunks + uploads, emits outcome ok/error and file_count = files actually sent. - telegram send_reply returns a {text_chunks, files_sent, ok} summary (checking api()/send_file() return values) and no longer emits; the two callers send any external parsed.actions attachments, then emit ONE event with the real outcome + delivered file count. Failed deliveries now surface as outcome="error" (which obs never samples away). Tests: channel.test.py asserts tier normalization (other→public, unknown, unrecognized→unknown) + outcome passthrough; widen the structural window in telegram-bridge-reply-parsed-body.test.py to cover the loop body now that the per-reply obs emit sits inside it. Full py suite + tsc green. * fix(telegram-bridge): ceil division for channel.telegram.out text_chunks len(clean_text) // 4000 + 1 overcounted on exact multiples of 4000 (a 4000-char reply reported 2 chunks but the send loop emits 1). Use ceiling division (len + 3999) // 4000, which matches range(0, len, 4000) exactly. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…tizeKey (sonichi#1774) sanitizeKey diverged across the twins on astral input: TS `sanitizeKey("a😀b")` → `a--b` (the regex matched each UTF-16 surrogate half separately) while the Python twin → `a-b` (code-point `re.sub`). Add the `u` flag to KEY_SAFE_RE so JS matches by code point, collapsing an astral char to a single `-` like Python. Pin it with an `a😀b → a-b` row in the shared parity fixture — the one spot the twins could still silently drift (flagged by Maddy reviewing sonichi#1703, confirmed divergent before this fix). Latent in practice (real channel keys — snowflake ids, call-sids — are ASCII), but this closes the last known TS/PY gap the parity fixture exists to guard. Stand: Echo Act IV Pro
…undled onboard.sh (sonichi#1770) * feat(ag2-relay): deliver onboarding via out-of-band bootstrap, drop bundled onboard.sh Onboarding now lives outside this repo (a one-line bootstrap the relay operator provides) so there is a single source of truth and the repo stays generic. The relay skill keeps only the runtime client + protocol docs. - remove skills/ag2-relay/onboard.sh - SKILL.md: point 'connect' at the bootstrap (curl -fsSL "$AG2_ONBOARD_URL" | bash) - startup.sh: when not connected and $AG2_ONBOARD_URL is set, print the connect hint; no URL/domain baked in (operator-supplied) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * ag2-relay: hardcode the public onboard URL (owner-approved, option b) The onboard URL is an inherently-public entry point (you can't onboard people to a secret URL), so it ships literally in startup.sh + SKILL.md rather than env-driven. One copy-pasteable command connects a new instance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * refactor(onboarding): remove bundled ag2-relay skill; fetch connector on demand Deliver the relay connector out-of-band instead of bundling it in the repo. - Remove skills/ag2-relay/ entirely (SKILL.md + remote-task-client.py + test). The canonical copy lives in the onboarding bootstrap, not in OSS, so there is no second copy to drift. - startup.sh: provider-neutral, self-healing, tty-gated. Reads the channel env (channels/ag2space/.env: REMOTE_TASK_URL + REMOTE_TASK_TOKEN) like the other channels, fetches the connector to $CLAUDE_CONFIG_DIR/skills/ag2-relay/ on demand when missing, then starts it. Stays silent on headless/unconfigured boots; only nudges when interactive and configured-but-tokenless. Config + credentials stay host-local in the channel dir (not the vault-synced workspace); no onboarding URL is hardcoded in the repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * refactor(startup): mirror discord/telegram pattern for the ag2 relay block Per review: resolve the channel .env via the same claude-home-path helper the discord/telegram/slack blocks use, gate on the token in channels/ag2space/.env, and start the connector if present. Drops the bespoke self-heal curl (its URL was the relay host, not the connector-source host) — the onboarding bootstrap is the sole installer. Honors a legacy repo-.env AG2_REMOTE_TOKEN for back-compat, and stays silent when unconfigured so a Sutando-only user never sees it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * feat(relay): bundle generic remote-relay-bridge in src/ + document the protocol Per review: rather than delete the relay connector and fetch it out-of-band, keep it as a generic, provider-neutral bridge in src/ — consistent with the discord/slack/telegram bridges, and a real "Sutando as a remote HTTP worker" primitive in its own right. - Add src/remote-relay-bridge.py — the former relay connector, moved into src/ and scrubbed of all provider-specific naming (REMOTE_TASK_* env only). - Add src/remote-relay-bridge.test.py — moved + renamed; passes. - Add docs/remote-relay-protocol.md — provider-neutral spec of the four /v1 endpoints so anyone can implement a relay server. - startup.sh: relay block now identical in shape to the discord/slack blocks, starting the bundled bridge when channels/<provider>/.env supplies a token. Only provider-specific onboarding stays out-of-band; the generic transport ships in the repo. No hardcoded URLs; nothing names a specific provider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * fix(relay): legacy AG2_REMOTE_* fallback in startup + clarify combined-token in docs Review of sonichi#1770 caught a rollout-compat blocker (P1) and a doc nit (P3). P1: existing agents onboarded by the current ag2space-cli have the token in the repo .env as AG2_REMOTE_TOKEN (combined "url|secret"), not in channels/ag2space/.env. The simplified startup block only detected REMOTE_TASK_TOKEN, so those agents would silently stop reconnecting after this lands. Restore the back-compat: detect a token from the channel .env OR a legacy AG2_REMOTE_TOKEN in the env, and map AG2_REMOTE_{TOKEN,TIER} → REMOTE_TASK_* (the names the bridge reads; the combined token is split by the bridge). Stays silent when nothing is configured. P3: docs/remote-relay-protocol.md now recommends the split URL+TOKEN form and documents the combined "url|secret" token as a legacy/bootstrap shortcut that must be quoted in .env (an unquoted | is a shell pipe when sourced). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * docs(relay): fix duplicated var in onboarding comment (rename artifact) The blanket AG2_REMOTE_*→REMOTE_TASK_* rename left a comment reading "REMOTE_TASK_URL/REMOTE_TASK_URL"; the bare-secret form needs a single REMOTE_TASK_URL alongside it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP * fix(relay): honor legacy AG2_REMOTE_* as deprecated aliases in the bridge Review: removing the AG2_REMOTE_* env fallback outright silently breaks agents onboarded under v0.4.0 (token lives in .env as AG2_REMOTE_TOKEN). startup.sh already maps the legacy names, but the bridge should be self-sufficient under any launcher. Restore AG2_REMOTE_{TOKEN,URL,TIER} as DEPRECATED aliases for one release (remove next), with a one-line stderr migration nudge when they're used. New onboards use REMOTE_TASK_* and see nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjBorURirQ2eV6TCqHTNUP --------- Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e welcome flow (sonichi#1776) A workspace-scoped $CLAUDE_CONFIG_DIR starts without `hasCompletedOnboarding` in .claude.json, and the core runs detached/non-interactively (`--`), so it never *completes* onboarding to persist the flag. Every launch then dead-ends at the welcome/theme flow the moment the user attaches the Core CLI. Seed just that flag in $CLAUDE_CONFIG_DIR/.claude.json (merge — never clobber oauthAccount/projects/mcpServers/credentials), carrying `theme` from the user's global ~/.claude.json when present so the theme picker is skipped too. Idempotent + atomic. Does not touch any permission/approval gate (e.g. bypassPermissionsModeAccepted) — that stays the user's to accept. start-cli.sh is the single launch chokepoint (Sutando.app launchCore, the terminal-server Core CLI pane, and src/startup.sh all exec it), so seeding here covers every launch path. Co-authored-by: jsun-m <jsun-m@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Mini" (sonichi#1780) The pending-question DM reminder hardcoded "edit pending-questions.md on the Mini to resolve", wrong on any non-Mini node. Use the actual short hostname so it points at the node that holds the question (pending-questions.md is per-host). Flagged by sonichi in #bugs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ORKING_DIR) (sonichi#1785) When SUTANDO_CLAUDE_WORKING_DIR is set, launch the tmux-wrapped core in that directory (tmux `-c`, plus a `cd` on the no-tmux fallback) and pre-accept Claude Code's folder-trust dialog for it by seeding projects[<dir>].hasTrustDialogAccepted in the workspace-scoped .claude.json. Unset (the default) is a no-op: the core launches from $REPO exactly as before and no trust entry is written. The working dir is canonicalized ONCE (expand a leading ~, mkdir, `cd && pwd -P`) and the same physical absolute path feeds tmux -c / the no-tmux cd AND the trust-seed key — so the key matches the core's process.cwd() (getcwd, symlinks resolved). A symlinked, relative, or trailing-slash value would otherwise seed the wrong projects[...] key and the detached no-TTY core would still hang at the trust prompt. mkdir fails loud with a scoped message rather than a raw set -e abort. Note added that tmux `-c` applies on session create, not attach. Rationale: a machine-spawn launcher may need the detached core to run in a stable directory other than the moving code checkout. --dangerously-skip-permissions does NOT bypass the folder-trust prompt, and a no-TTY session can't answer it. Co-authored-by: jsun-m <jsun-m@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot dropped SUTANDO_WORKSPACE (sonichi#1698 residual) (sonichi#1754) * fix(context-source-guard): resolve workspace via CLAUDE_CONFIG_DIR, not dropped SUTANDO_WORKSPACE (sonichi#1698 residual) The context-source-guard hook resolved its workspace with `os.environ.get("SUTANDO_WORKSPACE", "~/.sutando/workspace")`. v0.8/sonichi#1440 dropped SUTANDO_WORKSPACE for resolution, and `~/.sutando/workspace` is the pre-M0 location — so on a relocated install the hook read/wrote active-serving-channel.json in the LEGACY workspace while discord-bridge.py (M0 resolve_workspace) used `<repo>/workspace`. The two disagreed → the guard couldn't resolve the serving channel → it silently failed OPEN (the one component you least want failing open), and stranded a stray state file in ~/.sutando/workspace/state/. Same fail-open class as the ACCESS_FILE/ENV_FILE residuals already fixed to _CFG in sonichi#1698; WS was the one missed. Fix: derive the workspace from CLAUDE_CONFIG_DIR, whose parent is the workspace (`<workspace>/.claude-sutando` per the workspace contract) and which moves with a config-relocated workspace. No subprocess (this hook fires on every Bash/Read), no __file__ walk (the bundled-symlink anti-pattern documented in src/workspace_default.py), no dropped SUTANDO_WORKSPACE. Test: updated the fixture to point CLAUDE_CONFIG_DIR at the temp workspace (was SUTANDO_WORKSPACE) and added a regression asserting a stray SUTANDO_WORKSPACE no longer redirects resolution. Full suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(context-source-guard): reword comment to satisfy workspace-resolution lint The explanatory comment in the prior commit literally contained the legacy ~/.sutando-based path; the CI workspace-resolution lint greps added lines for that pattern (it can't tell code from a comment) and failed the check. Reword to describe it without the literal path. No code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(context-source-guard): nearest-.claude-sutando-ancestor WS resolution + document fallback parity (sonichi#1754 review) Addresses qingyun-wu's non-blocking review points on sonichi#1754: 1. Walk up to the nearest `.claude-sutando` ANCESTOR instead of an exact-leaf basename match. Survives the floated per-host CLAUDE_CONFIG_DIR subdir (`<workspace>/.claude-sutando/hosts/<host>`) — there the leaf is the hostname and an exact match would wrongly fall through to the fallback. 2. Document that the `~/sutando-workspace` fallback is not arbitrary: it is the canonical last-ditch (workspace_default.default_workspace_dir() / _DEFAULT_SUBPATH), so an unresolvable CLAUDE_CONFIG_DIR fails open to the same place the rest of the system uses, not a phantom dir. Adds test case 8: a per-host CLAUDE_CONFIG_DIR resolves back to <WS> so the blacklist still applies (DENY). Lint --diff clean; full suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…acy ~/.sutando/workspace (sonichi#1765) * fix(obsidian-vault): resolve vault under canonical workspace, not legacy ~/.sutando/workspace The vault path resolver still used the pre-v0.8 fallback `$SUTANDO_WORKSPACE || ~/.sutando/workspace`, even though SKILL.md already documents the canonical resolution (`bash scripts/sutando-config.sh workspace`, default `<repo>/workspace/`; `$SUTANDO_WORKSPACE` no longer honored as of sonichi#1440). With SUTANDO_WORKSPACE unset, both call sites fell back to `~/.sutando/workspace/obsidian-vault` — the legacy "source B" location — so captures and the nightly dream linker stranded a vault outside the real workspace (observed on a migrated node: `~/.sutando/workspace/obsidian-vault` holding `.obsidian/` + `Sutando/`). Fix: resolve via the shared canonical helper at both sites — - `tools.ts` (voice-inline `add_to_vault`): `resolveWorkspace()` from `../../src/workspace_default.js` (same import pattern as screen-companion). - `scripts/dream.py` (nightly linker): `resolve_workspace()` from `src/workspace_default.py`. Now both land in `<workspace>/obsidian-vault`, matching SKILL.md. Also drops the now-unused `homedir` import and updates the stale `$SUTANDO_WORKSPACE/...` path references in the header comments, tool description, and manifest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(obsidian-vault): reword comments to satisfy workspace-resolution lint The explanatory comments referenced the literal legacy path, which the lint-workspace-resolution '.sutando/workspace' grep refuses even inside comments. Reword to 'legacy home-dir fallback' — no code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cal resolve_workspace (sonichi#1786 sweep, phase 1) (sonichi#1790) Part of the systematic workspace-migration sweep (audit: notes/workspace-migration-residual-systematic-fix.md). These three scripts resolved the workspace via an ad-hoc `SUTANDO_WORKSPACE` env read with a legacy `~/.sutando/workspace` fallback — exactly the divergence class that keeps surfacing one PR at a time (sonichi#1754, sonichi#1765, sonichi#1766): - skills/audio-transcribe/scripts/transcribe.py - skills/voice-agent-test-harness/scripts/report.py - skills/voice-agent-test-harness/scripts/score.py All three now import the canonical `resolve_workspace()` and drop the legacy fallback. No behavior change in the live system (CLAUDE_CONFIG_DIR is exported, so the resolver returns the same <repo>/workspace these scripts were already targeting) — but they no longer silently diverge to ~/.sutando/workspace when the env var is unset. The import bootstrap uses `Path(__file__).resolve().parents[3]` (not a .parent.parent chain) so the workspace-resolution lint doesn't conflate loading the helper with resolving the workspace. Scope note: this is phase 1 (3 leaf scripts, zero blast radius). Deliberately excluded: skills/.../baseline.py (uses a skill-LOCAL results path, so migrating it is a location decision, not a resolver swap — separate follow-up); the bash scripts (prong B); the helper's central unset-CLAUDE_CONFIG_DIR fallback (prong C, coupled with sonichi#1754's review); and flipping the lint to full-tree (prong D, must wait until sonichi#1754/sonichi#1765 land and the tree is clean). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…plicate Task entries (sonichi#1784) notify_discord_dm() named its proactive-pending-q-*.txt reminder file with a per-fire timestamp. The filename stem becomes the task id (TaskBridge forwards proactive-* files via _sendTaskStatus), and web-client updateTask() upserts the Task list by that id -- so every re-fire of the same unanswered question set created a new id and accumulated one duplicate row per fire. Name the file from questions_key() -- sha256[:16] of the sorted question titles (same convention as health-check.py) -- so an unchanged set reuses one id (collapses to one row) and a changed set produces a new one. Discord still DMs each fire. Scoped to notify_discord_dm only: notify_voice's question-*.txt is not forwarded to the Task list (_shouldFallthrough excludes the question- prefix) and is short-lived (consumed + deleted by voice / dm-fallback within the 90s grace, well under the 1h reminder cooldown), so it neither duplicates nor accumulates. Adds tests/check-pending-questions-collapse.test.py. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…sonichi#1477) Voice agent currently greets with a multi-sentence "Hey, I'm right here. What can I do for you?" whenever it thinks it heard its name, including on ambient false-positives and bare "are you there?" presence checks. Owner reported this is annoying enough to interrupt other work. Adds a CRITICAL RULES bullet directing the model to respond with one short syllable ("mm?" / "yes?") when: - it isn't fully confident the wake-word was actually spoken - the wake-word was spoken but no request is attached (bare "Sutando", "are you there", "hello") Full greeting is reserved for clear addressing PLUS a real request / question. A wrong short ack is cheap; a wrong long greeting is what the owner is complaining about. System-instruction-only change (src/voice-agent.ts CRITICAL RULES block, between FILLERS and the NEVER-pretend rules). No code-path / tool / default-value changes. tsc --noEmit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…-loss (sonichi#1763) Owner saw several Discord messages received (logged at the "@user: text" print) but produce NO task file and NO error — silently dropped, eating even owner decisions. Every early `return` in _handle_discord_message already logs (dedup, plugin-hook, tier-ownership, silent-escalate), and none of those lines appeared for the dropped messages — so the gap is the task-file write itself: an exception in the f-string build or write_text would lose the message with no trace. Wrap the write in try/except (log + bail on failure) and log on success. Now every outcome is recorded: a future drop self-diagnoses — absence of BOTH a `[task-write]` line and an early-return log = a new/unmapped path; a `[task-write] FAILED` line = the write. Diagnostic only; no happy-path behavior change. py_compile clean. Stand: Echo Act IV Pro
…to bare-& (sonichi#1769) * feat(startup): prefer launchd-supervised credential-proxy, fall back to bare-& startup.sh launched the credential-proxy as a bare `npx tsx … &` with no supervision — if the proxy process died, a proxy-routed core was stranded on a dead :7846 port until the next manual restart. The launchd-supervised job (KeepAlive + ThrottleInterval=10s) already shipped in sonichi#1291 (src/install-credential-proxy-launchd.sh, closes sonichi#1086) but was never wired into startup, so fresh nodes never got crash-recovery. Wire it in: if the installer + plist template are present, ensure the launchd job is loaded (install if not already), wait for it to bind, then let the existing `lsof -i :7846` guard skip the legacy bare-& launch. On older checkouts lacking the template — or if the install fails — fall through to the unchanged bare-& path, so this is backward-compatible. The wrapper evicts any stale manual holder of 7846 before binding, so the two paths compose safely. Crash-recovery only; a 502 (proxy up, upstream failed) is out of scope and still needs the separate upstream-resilience fail-open. Stand: Echo Act IV Pro * fix(restart): handle launchd-supervised credential-proxy in restart/stop restart.sh pkill'd the credential-proxy unconditionally, which only bounced the worker — launchd's KeepAlive respawned it on its own throttle, so a restart never actually controlled the cycle. Mirror the startup-side launchd preference from the parent commit: if the launchd job is loaded, `kickstart -k` on a normal restart and `bootout` on `--stop-only` (so KeepAlive doesn't resurrect it); fall back to pkill only when no launchd job exists. Also drop credential-proxy from the bare-pkill list and the STOP_PATTERNS drain loop, since launchd now owns its lifecycle. The kickstart readiness wait uses `lsof -nP -iTCP:7846 -sTCP:LISTEN` rather than a bare `lsof -i :7846` — the latter also matches transient client connections and could break out of the wait before the proxy has rebound. Completes the launchd lifecycle for sonichi#1769 (start was half the story; restart and stop are the other half). Stand: Echo Act IV Pro --------- Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…rsonal_path (sonichi#1777) The tier-ownership check read `REPO / "stand-identity.json"` directly (REPO = resolve_workspace(), so the workspace-root copy only). Every other consumer (agent-api, dashboard, event_log, conversation-server) resolves identity via `personal_path("stand-identity.json")`, which probes hosts/<host>/ (the sonichi#1717 per-host home) → legacy machine-<host>/ → workspace root. So on a node whose identity lives in the per-host hosts/<host>/ location — the workspace-design canonical spot — the bridge saw an empty `machine`, and with SUTANDO_TEAM_TIER_OWNER set that silently DROPS all non-owner tasks (the exact failure its own startup warning describes). Switch the read to personal_path so the bridge agrees with every other identity consumer. Stand: Echo Act IV Pro Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…tructions (sonichi#1782) * fix(discord-bridge): inject CONTEXT-FIRST reconstruct step into owner task instructions Owner messages arrive as discrete task files processed against a session context that compacts and is interleaved with cron passes — so terse/threaded replies ('no', 'continue', a pronoun) get interpreted from stale memory and lose their referent. Add a CONTEXT-FIRST step (step 1, before NOTIFY) to the owner-task instruction block telling the agent to reconstruct from the durable channel BEFORE interpreting — keyed on the message not being self-contained (agent judgment, not a parent_message_id gate), following the reply chain back (no arbitrary message count), via src/discord-read.py. Point-of-use: rides the same per-task instruction block the agent already reliably follows (NOTIFY FIRST). Reconstruction is read-only. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refine CONTEXT-FIRST wording: unrestricted context, all participants, parent_message_id Per owner review: drop the owner-centric/2-party framing — reconstruct the *relevant context* (everyone's messages incl. my own prior replies), and use the task's parent_message_id to answer the message actually being replied to (not the linear latest). No magic count; follow the reply chain until the message stands on its own. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(discord-bridge): drop redundant parent_message_id directive from CONTEXT-FIRST The "use parent_message_id to answer the replied-to message, not the linear latest" line was fully redundant with the reconstruct rule: - the bridge already injects the [Replying to …] quote (referent is free) and threads the reply to the right message, so the agent never chooses which message to answer; - "reconstruct until it stands on its own" already covers a truncated [Replying to] quote (truncated = not self-contained → reconstruct → full message). Keep only the reconstruct rule. parent_message_id remains a task field for the bridge's own reply-threading. Stand: Echo Act IV Pro * feat(discord-read): add --before for backward pagination The CONTEXT-FIRST reconstruct rule points at discord-read.py, but the script only had --after (forward/latest), so it could not reach context more than ~100 messages back. The Discord API supports `before`; expose it so deep reconstruction works through the same tool instead of falling back to the session transcript. Stand: Echo Act IV Pro * feat(reconstruct): telegram reply-context + discord-read --until paging - telegram-bridge: embed reply/parent context (source_message_id, parent_message_id, "[Replying to …]" quote) into task files. Telegram Bot API has NO message-history fetch (getUpdates only), so embed-at-write is the only reconstruct substrate for Telegram — a terse reply was previously unreconstructable. - discord-read: add --until <id|iso> condition-based backward paging (auto-pages with before-cursor until the boundary, then stops). Depth is set by the boundary, not a magic message count; MAX_PAGES is a runaway backstop only. Verified: reconstructed 342 msgs back to a prior-day id. Stand: Echo Act IV Pro * feat(telegram-bridge): add CONTEXT-FIRST reconstruct instruction to task files Telegram tasks had no reconstruct instruction. Add CONTEXT-FIRST as step 1 (always emitted), mirroring discord-bridge, but pointing at Telegram's substrate: the embedded [Replying to …] quote + the session transcript — NOT a channel pull-back, since the Bot API has no message-history fetch. Completes multi-surface context reconstruction without a wrapper skill: Discord pulls back via discord-read --until; Telegram reconstructs from embedded reply-context + transcript. Stand: Echo Act IV Pro * fix(discord-bridge): ungate CONTEXT-FIRST from task-progress/transcribe skills CONTEXT-FIRST (reconstruct before interpreting) is a correctness feature but was gated behind `_notify_py.exists() or _transcribe_py.exists()` — unrelated skills gating it. On a node without task-progress/audio-transcribe installed, owner tasks got no reconstruct instruction. Emit for every owner task; the notify/transcribe steps remain conditional within. Mirrors telegram-bridge. Stand: Echo Act IV Pro --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…ws (sonichi#1786) (sonichi#1788) proactive-* result files pass _shouldFallthrough so the voice agent SPEAKS them (the proactive-voice delivery path), but they are not tasks. The fallthrough still fired _sendTaskStatus and POST /task-done for them, keying a task_history row by the file stem — so every re-fire of a proactive notification (e.g. the hourly pending-question reminder) with a fresh timestamp added a DUPLICATE Task row. Add _shouldRegisterTaskRow(file) = file.startsWith('task-') and gate both task-registration side-effects on it. proactive-* are still spoken (onResult) and archived; they just no longer create phantom Task rows. This is the general fix for the whole class — it covers any proactive-* notification, not only proactive-pending-q, and does not depend on stable filenames. It supersedes the narrow sonichi#1784, which only stabilized the pending-question filename so its duplicate rows collapsed to one. Test: extend tests/task-bridge-fallthrough-guard.test.ts with a _shouldRegisterTaskRow suite (task-* register; proactive-*/voice-*/question-* do not). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onichi#1781) * fix(web-client): tag tasks by source field instead of [Voice]-by-default The web task list stamped "[Voice]" on ANY task whose text had no [Channel] prefix, so non-voice items (cron/system reminders) that reached the list were mislabeled as voice. Now tag by the task's `source:` field: source=voice -> [Voice], anything else -> [System]. agent-api exposes `source` in /tasks/active; web-client carries it through taskMap and uses it for both render paths. Flagged by sonichi + susanliu_ in #bugs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-api): parse source: regardless of field order (sonichi#1781 review) sonichi's review noted the source: parse broke at the first task: line, so it captured source only when source: preceded task:. voice/chat tasks put source: first (works), discord/slack put task: first. Now capture the first source: and first task: regardless of order, with not-already-set guards so a task body cannot override the real header source:. Verified both orders: voice-format -> [Voice], task-first -> source read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fig.local.json override
4 tests in tests/workspace-default.test.py were failing when a dev checkout
carries sutando.config.local.json with workspace.path set — a valid user
override that legitimately changes the resolved path but broke the "no-config
fallback = <repo>/workspace" contract tests.
Fix: add _patch_no_workspace_config() helper that (1) warms _CACHE_REPO_ROOT
by calling the real load_config() first, then (2) patches load_config to
return {} so resolve_workspace falls through to the in-repo default. The 4
affected tests now use this helper instead of calling resolve_workspace()
bare, making them config-file-agnostic.
All 44 tests pass (4 pre-existing skips unchanged).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXQJogmVSdYrKtYX1LwzwT
…l-backend) (sonichi#1869) * feat(room-read): pull-on-demand room history for an agent (gated, dual-backend) Gives an agent a read capability: pull recent messages from a room/channel on demand, so it can reference prior discussion instead of only seeing messages routed to it. Orthogonal to the task file bridge (tasks/->results/) — a separate synchronous pull, the async loop is untouched. Two interchangeable backends behind one per-agent default-deny gate and one interface: - generic: GET {RELAY_URL}/v1/rooms/{room}/messages?limit=N (provider- agnostic; relay maps it to whatever platform it fronts) - appservice: masquerade read against the homeserver CS API (privileged) Per-agent scope gating is opt-in/never-blanket (default-deny). Graceful degrade on missing creds / gate-deny / 404 (verb unimplemented) / 403 (non-member) / network. No platform literals in the code — all config via env/vault. 19 unit tests. Sibling to src/discord-read.py (the Discord read precedent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * docs(room-read): document read-requires-joined-membership consent model Records the scope-boundary decision: room-read is gated behind actual joined membership (transparent participation), not a privileged read-without-join. Both backends enforce it (masquerade 403s for non-members; the generic relay verb must check membership). read-without-join is reserved for bridging/infra roles, not participant agents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * fix(room-read): drop direct workspace-env resolution in gate path (CI lint) The CI lint 'refuse new direct workspace-path resolution' flagged the os.environ.get("SUTANDO_WORKSPACE_RESOLVED") lookup (prefix-matches the SUTANDO_WORKSPACE pattern). Resolving the workspace is the caller's job via the sanctioned helper; the skill now just reads ROOM_READ_GATE (caller-provided absolute path) or a cwd-relative fallback. Docs updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * docs(room-read): add bot-client parity acceptance bar Codifies the requirement that an agent must be no worse than a chat bot-client (e.g. Discord) on any capability axis. Room-read closes the one gap below that floor; message-level reactions flagged as the remaining parity item (add via the same generic-verb pattern). Makes 'no worse than the bot-client' an explicit, reviewable acceptance criterion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * refactor(room-read): client speaks relay verb only — no local AppService token Enforces the architecture boundary (owner 2026-06-30): the local client must NOT hold the AppService/platform token. Removes the local appservice masquerade backend (which read AS_TOKEN and hit the homeserver directly). The skill now has a single backend — the generic relay verb GET /v1/rooms/{room}/messages — and the relay/broker (box-side) is what uses the AppService. Membership is enforced relay-side (authoritative); the client gate is an optional defense-in-depth pre-filter (absent gate file -> defer to relay). Renames the skill dir appservice-room-read -> room-read to match. Tests rewritten (18, no network). SKILL.md documents the layering + the parity epic checklist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * fix(room-read): exit 0 on graceful no-context + clamp limit (review nits) Addresses the non-blocking review notes on sonichi#1869: - CLI exits 0 for any structured result (a graceful ok:false 'no context' is not a failed task); usage errors exit 2, unexpected bugs raise. - limit clamped to [1, MAX_LIMIT=100] so a caller can't turn a context-first read into an accidental large history pull. 22 tests (4 new for clamp + exit code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar --------- Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ill (sonichi#1878) * refactor(agent-room-ops): consolidate parity slices into one skill collection Owner concern: N tiny skills (room-read/room-media/room-react) fragment the collection, duplicate the relay/gate helpers 3x, and clutter skills/. Fix: one skill = one collection, multiple tools. - New skills/agent-room-ops/ (provider-agnostic name) with shared _relay.py (relay coords + per-agent gate + http + degrade — DRY) and per-capability modules read.py/media.py/react.py dispatched by room_ops.py. - Folds in the merged room-read (removes standalone skills/room-read/) as the read tool; supersedes open room-media (sonichi#1876) + room-react (sonichi#1877) as the fetch/send + react/unreact tools. - One SKILL.md; same boundary as sonichi#1869 (relay-only, membership relay-enforced, gate, graceful-degrade, exit-0). 27 tests. (Manual workspace-lint clean; hook skipped due to a hang, CI re-checks.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * fix(agent-room-ops): address review — bound inbound read, gate-first, skill-relative gate path Integrates @sutando-sonichi's sonichi#1878 review: - [moderate] fetch_media now does a BOUNDED read (max_bytes=MAX_BYTES+1) so a hostile/buggy relay returning a huge body can't OOM the agent before the 25MiB cap. (_relay.http_request gains an optional max_bytes.) - [minor] send_media gates FIRST (before isfile/path/size) — cheapest deny, no filesystem touch for an unauthorized agent. - [minor] gate_path default resolves relative to the skill dir (not cwd) so the client default-deny stays reliable regardless of the process cwd. - Added 2 regression tests (bounded-read-rejects-oversize; gate-before-stat). 29 tests total. (hook skipped due to the earlier transient issue; CI re-checks.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * fix(agent-room-ops): address owner Codex review — combined token, outbox allowlist, Content-Length Integrates qingyun-wu (Codex) review of sonichi#1878: - [P1] relay() now honors the one-token onboarding contract: REMOTE_TASK_TOKEN may be the combined 'https://relay|secret' form (matches remote-relay-bridge.py). Precedence: explicit RELAY_URL/REMOTE_TASK_URL > url-from-token; explicit RELAY_TOKEN > secret-from-token. Fixes standard installs degrading 'no RELAY_URL' or sending the wrong bearer. - [P1] outbound media allowlist now fails (mostly) closed: default is a dedicated ROOM_MEDIA_OUTBOX (temp subdir the skill owns), NOT the whole OS temp dir — a random /tmp file is no longer silently uploadable. - [P2] fetch_media checks Content-Length up front (skips the read entirely when the peer declares oversize) in addition to the bounded read. - +8 tests (combined-token + precedence; outbox denies random temp, allows outbox/ explicit dir; CL-declared-oversize reject). 37 total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar * refactor(agent-room-ops): rename relay -> gateway (owner decision) The box-side component owns the platform creds + is the authoritative membership gate + translates /v1 -> CS-API — a privileged gateway, not a dumb relay. Owner picked 'gateway'; renamed the client-side references (nouns only, behavior unchanged): - _relay.py -> _gateway.py; relay() -> gateway() - GATEWAY_URL / GATEWAY_TOKEN are now primary; RELAY_URL / RELAY_TOKEN / REMOTE_TASK_* kept as transition aliases (nothing breaks mid-migration). - SKILL.md / comments / reason strings updated. +2 alias-precedence tests (39). (remote-relay-bridge.py -> remote-gateway-bridge.py is the paired follow-up PR.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUkwCDVZYi2JqPe5uZ5Bar --------- Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Summary
TestWorkspaceDefaultwere failing whensutando.config.local.jsonhasworkspace.pathset (a valid dev-checkout override), because the tests assert the "no-config fallback =<repo>/workspace" contract without mocking out the config_patch_no_workspace_config()helper that warms_CACHE_REPO_ROOTfirst (soresolve_workspaceknows the repo root), then patchesload_configto return{}— stripping the workspace.path override only for the duration of these specific testsRoot cause:
sutando.config.local.jsonin the dev checkout at/Users/bassilkhilo/Projects/sutando/has{"workspace": {"path": "/Users/bassilkhilo/.sutando/workspace"}}, which overrides the expected default of<repo>/workspace. Without isolation, all 4 fallback-path tests got the user's actual workspace path instead of the repo-relative default.Critical detail: must call
sc.load_config()before patching — otherwise_CACHE_REPO_ROOTis None andresolve_workspacefalls to~/sutando-workspace/(the no-repo-root fallback).Test plan
python3 tests/workspace-default.test.py— all tests pass including the previously-failing 4🤖 Generated with Claude Code
https://claude.ai/code/session_01KXQJogmVSdYrKtYX1LwzwT