Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

test(workspace-default): isolate fallback-path tests from local config override - #115

Open
bassilkhilo-ag2 wants to merge 323 commits into
AG2Platform:mainfrom
bassilkhilo-ag2:fix/workspace-default-test-isolation
Open

bassilkhilo-ag2 wants to merge 323 commits into
AG2Platform:mainfrom
bassilkhilo-ag2:fix/workspace-default-test-isolation

Conversation

@bassilkhilo-ag2

Copy link
Copy Markdown

Summary

  • 4 tests in TestWorkspaceDefault were failing when sutando.config.local.json has workspace.path set (a valid dev-checkout override), because the tests assert the "no-config fallback = <repo>/workspace" contract without mocking out the config
  • Added _patch_no_workspace_config() helper that warms _CACHE_REPO_ROOT first (so resolve_workspace knows the repo root), then patches load_config to return {} — stripping the workspace.path override only for the duration of these specific tests
  • 4 failing tests now updated to use the helper

Root cause: sutando.config.local.json in 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_ROOT is None and resolve_workspace falls to ~/sutando-workspace/ (the no-repo-root fallback).

Test plan

  • python3 tests/workspace-default.test.py — all tests pass including the previously-failing 4
  • No production code changed; tests only

🤖 Generated with Claude Code

https://claude.ai/code/session_01KXQJogmVSdYrKtYX1LwzwT

VasiliyRad and others added 30 commits May 22, 2026 15:59
…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>
sonichi and others added 30 commits June 24, 2026 07:58
…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>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.