This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Repository navigation
Upstream sync phase 3 — CP-8–CP-12, conversation-store, and the upstream merge - #38
Merged
Merged
Conversation
AppleScript's `keystroke "..."` routes characters through virtual-key codes that can't represent anything outside basic ASCII. Emoji (🤖), em-dashes (—), curly quotes — all get corrupted: UTF-8 bytes get reinterpreted as Mac Roman (e.g. em-dash bytes E2 80 94 → "‚Äî", robot emoji 4-byte F0 9F A4 96 → garble). Add a `hasNonAscii` check (/[^\x00-\x7f]/) to the same conditional that already routes multi-line / long text through the clipboard-paste branch. The paste branch round-trips through pbcopy → System Events Cmd-V, which preserves bytes losslessly. Per Chi 2026-05-13: typed the Mini DeepMind X-post draft into Cursor via voice, em-dashes got mojibaked, then 🤖 got garbled. Workaround of "I'll put it in your clipboard manually" wasn't a fix — this is. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to sonichi#660. The first fix routed emoji + non-ASCII text to the paste branch, but pbcopy still corrupted multi-byte UTF-8 in the voice-agent's launchd context — Chi reported 🤖 still didn't make it to Cursor after restart. Root cause: voice-agent runs under launchd which doesn't inherit the terminal's LANG/LC_CTYPE. The child shell that ran `pbcopy < tmpClip` defaulted to POSIX/C locale; pbcopy in that locale doesn't pass multi-byte sequences through cleanly — they end up as "??" on the pasteboard. Two changes: 1. Set `LANG=en_US.UTF-8` + `LC_ALL=en_US.UTF-8` on every execSync call in the paste branch via the `env:` option. 2. Switch from `writeFileSync` + `pbcopy < file` to `execSync('pbcopy', { input: pasteText })` — bypasses the shell redirect entirely and pipes bytes directly to pbcopy's stdin. Same approach for the clipboard restore step. Eliminates a class of locale/encoding bugs. Side benefit: no /tmp files leaked, no unlinkSync needed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#662) Chi 2026-05-13: screen recordings came out silent. Diagnosis: the tool passed `screen:default` to avfoundation, and `default` on this Mac resolves to ZoomAudioDevice (idx 0), not the actual MacBook mic (idx 1). ZoomAudioDevice carries no audio when Zoom isn't actively routing a call → silent track. Add `_pick_audio_device()` that: - Prefers built-in MacBook mic (matches /MacBook.*Microphone/ — covers Air / Pro / etc). - Falls back to first non-virtual device (skips ZoomAudioDevice, Microsoft Teams Audio, BlackHole, Aggregate Device). - Last resort: index 0 (system default — may be virtual but at least something). For Zoom-call recordings specifically, set `RECORD_AUDIO=ZoomAudioDevice` explicitly. The picker deliberately doesn't auto-detect "in a Zoom call" because Zoom-running ≠ Zoom-streaming-audio, and a heuristic miss would just give Chi silence again. Plus extracted `_list_audio_devices()` so both `_has_audio_device()` and `_pick_audio_device()` share the parse. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…i#663) Chi 2026-05-13: the existing `cancel_task` voice tool only deleted the task file. It never stopped in-flight processing because once core had the task content in its context, unlinking the file did nothing. Chi's design (which is the right shape): write a NEW task with a `CANCEL_INSTRUCTION:` header that names the target. Core picks it up through the normal task pipeline and either: - aborts in-flight work on the named target, or - no-ops if the target was already completed. Trade-off Chi accepted: cancel takes effect when core reaches the cancel-instruction task in its queue, not mid-turn. That's enough for "nevermind"-style intents that today often fire before core picks up the original. Changes: - Rewrite `cancelTaskTool.execute` to write the CANCEL_INSTRUCTION task (best-effort unlink the original + still emit "Cancelled." result so the web UI shows the cancelled state for the original task ID). - Update tool description: "writes a CANCEL_INSTRUCTION task that core will see next — doesn't interrupt a single LLM turn." - New return shape: `{status: 'cancel_instruction_queued', taskId, instruction}`. - CLAUDE.md task-bridge section gets a "Cancel handling" paragraph so core recognizes the convention and reacts correctly. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chi 2026-05-13: "the type_text tool is consistently replacing my selected text in Chrome instead of appending to it." That's standard macOS Cmd-V semantics (paste replaces selection), not a bug — but voice users want "add this to the end" by default for many intents, and there was no way to express that. Add a `append: boolean` parameter (default false = current replace behavior). When true: - Paste branch: send Right-arrow via AppleScript key code 124 before Cmd-V. Right-arrow collapses any active selection to its end and leaves the cursor there; the subsequent paste lands AFTER the selection instead of replacing it. - Keystroke branch: same — Right-arrow first, then the keystroke sequence. System Events keystroke into a selected region behaves the same as Cmd-V (replace), so the deselect helps there too. Tool description updated so voice (Gemini) knows when to pass append=true (says "add" / "append" / "type at the end") vs the default replace (says "type X here" / "replace this with"). Log line gets a `(append)` tag so we can tell modes apart in the voice-agent log. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#665) Chi 2026-05-13: "the voice agent lost context. Can it auto reload context from recent tasks and results? ... do this only in active mode. ... hybrid." Voice-agent's Gemini context window rolls off after ~10 minutes of turns. After a long debug session this morning Chi had to remind voice ~5 times what "the post" or "Mini Draft A" referred to. The fix: a small JSON state file that core writes to whenever a durable decision lands, plus a voice-callable tool to read it. This PR ships the read path (the tool) and the writer convention (a CLAUDE.md note). It deliberately defers the per-turn auto-injection into Gemini's system message — that's a deeper voice-agent change and gets a follow-up PR. The tool is enough for the immediate gap: voice can call `recent_context()` whenever it senses confusion. Changes: - New `recentContextTool` (inline) at src/inline-tools.ts:805. Returns the parsed JSON content of `state/voice-session-context.json` or a `{note: 'no context recorded yet'}` placeholder if the file is missing. Registered in both `inlineTools` and `ownerOnlyTools` arrays. - CLAUDE.md task-bridge section gets a "Voice session context" paragraph: schema (active_drafts / pending_action / last_results), prune rule (~3 entries each), when to write (durable decisions). - File at `state/voice-session-context.json` is gitignored via existing `state/*` rule — runtime data, not source. Schema (informal, documented in CLAUDE.md): ```json { "updated_at": "ISO ts", "active_drafts": [{"name": "...", "summary": "...", "path": "..."}], "pending_action": {"kind": "paste|...", "what": "...", "where": "..."} | null, "last_results": [{"task_id": "...", "subject": "...", "ts": "..."}] } ``` Active-mode gate (Chi's constraint): only relevant if/when we add auto-injection. The `recent_context` tool itself is model-triggered, which is already "active-mode" by definition — Gemini only calls tools during live sessions. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…i#666) * feat(framing): My AI Stand — README hero + web-client tagline README hero (lines 5+7) and web-client.ts:641 swap to the locked "My AI Stand" framing. Hero is now "Realtime by Day, Coding Itself by Night"; supporting line opens up the surface from "screen/meetings/phone calls/builds itself" to "Realtime when I'm engaged. Coding itself when I'm not. Runs across my Macs, interacts with people & their Stands." Why now: 3 weeks until Observe Summit (June 4); updating before the talk gives search + social time to pick up the new framing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(framing): add voice/vision/screen/meetings/calls to L7 Per Chi's call: include the concrete capability surface in the hero so cold readers can anchor what "interacts with people & their Stands" means in practice. Echoes the L5 title's "by day / by night" cadence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chi 2026-05-13: PR sonichi#662 made `_pick_audio_device` route to the MacBook Air Microphone correctly, but a 17:28 recording still came back as pure digital silence (-91 dB). Chi confirmed he didn't mute. Most likely a transient mic-acquisition failure — ffmpeg started, captured nothing on the audio stream, finished cleanly. The user got a "stopped" status and discovered the silence only when they opened the file in QuickTime. Add a post-stop `volumedetect` guard. After the recording file is finalized, run ffmpeg with the volumedetect filter, parse mean_volume, and if it's below -80 dB (the silence floor — real voice mean is typically -17 to -25 dB), include an `audio_warning` field in the stop() JSON output. The voice agent / CLI / web UI can surface this to the user immediately instead of letting them play silence later. Wrapped in try/except so a guard failure (ffmpeg missing, etc.) never breaks the existing stop() return path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hi#668) Chi 2026-05-13 deeper audit: silent recordings were untraceable because record.py was throwing away ffmpeg's stderr (stderr=DEVNULL). When ffmpeg failed to acquire the mic (HAL exclusive contention with voice-agent, permission denial, device-list shift), the error was invisible — ffmpeg kept running, captured video + a silent audio stream, finished cleanly, and the user discovered the silence later in QuickTime with no diagnostic trail. Two changes: 1. **Capture ffmpeg stderr** to `<recording-path>.ffmpeg.log`. Store the log path + the resolved input_spec in the PID file so stop() can find them later. 2. **Match known audio-failure signatures** in stop()'s silence-guard path. When `audio_warning` fires (mean_volume < -80 dB), scan the ffmpeg log for known patterns: - "Permission denied" → macOS Microphone permission denied - "Cannot find audio device" → device list shifted between picker and record-start - "device or resource busy" → another process holds the device (likely voice-agent or Zoom) - "kAudioHardwareNotRunningError" → CoreAudio sick - "Inappropriate ioctl" → HAL exclusive vs shared mode mismatch - "Operation not permitted" → macOS sandbox/TCC denial If any hit, add `audio_root_cause` to the JSON output alongside the existing `audio_warning`. Plus `ffmpeg_log` (full path) + `input_spec` (what we asked ffmpeg to record) so the caller can investigate. The guard's behavior is unchanged when audio is fine — no extra fields land in the output. Only fires on silent-recording cases. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ences (sonichi#669) Add a new CRITICAL RULE before MISSING CONTEXT: when the user uses a deictic word (this/that/it/"fix this") without conversational antecedent, call read_selection FIRST to capture what's on screen, instead of asking "which one do you mean?". Only fall through to a clarifying question if read_selection returns empty AND prior conversation doesn't resolve it. Per Chi voice-task 2026-05-13: "always proactively call read_selection when the user refers to something contextually." Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(framing): drop the by-day/by-night echo from L7
The L5 title already carries the "Realtime by Day, Coding Itself by Night"
polarity. L7 was repeating it — strong as cadence anchor but reads redundant
on second glance, especially on mobile where L5/L7 may not be vertically
adjacent. Per Chi: trade rhythm for tightness.
Old: Realtime by day — voice, vision, screen, meetings, calls. Coding
itself by night. Runs across my Macs, interacts with people & their
Stands.
New: Voice, vision, screen, meetings, calls when I'm engaged. Code when
I'm not. Runs across my Macs, interacts with people & their Stands.
23 words vs 26. Same capability surface; no echo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(framing): broaden L7 night-mode — add research/learning surface
L7 was "Code when I'm not" which (a) is ungrammatical (dangling "not")
and (b) understates the actual night-mode surface. Per Chi: research +
learning is the upstream that drives the engineering verbs.
B8 lands two verbs:
- "Learns my patterns" — covers research, learning, memory, observation
- "Ships its own code" — covers dev autonomy + deployment
Voice, vision, screen, meetings, calls when I'm engaged.
Learns my patterns, ships its own code when I'm not.
Runs across my Macs, interacts with people & their Stands.
Same two-verb shape that the talk abstract uses ("learns my patterns,
... ships code"), so README + speaker form reinforce each other.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Chi <wangchi@Chis-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#672) Closes part of sonichi#671. The 600px step has been hardcoded since PR #88 when scroll was a phone-demo nudge. Today's call exposed the gap when caller asked to "scroll a little" past a window occluding the draft area — default 600 overshot in both directions. amount='small' (150px), 'medium' (400px, default), 'large' (800px). Tool description teaches the agent to pick small for "a little" / "a bit" phrasings. Task-expand all-or-nothing (the other half of sonichi#671) is left for a separate PR — needs a web-client change too.
…hi#673) Voice agent root-caused 2026-05-13: Gemini called press_key(key="downarrow") during a scrolling task; the keyMap only had 'down' → 125. With no match, the code fell through to `keystroke "downarrow"` which types the 9 literal characters d-o-w-n-a-r-r-o-w into the focused field instead of pressing the down arrow key. User reported "space works but arrows don't" — exact symptom of this fall-through (space WAS in the keyMap so it sent key code 49 correctly). Adds 8 aliases: uparrow/downarrow/leftarrow/rightarrow and the arrowup/arrowdown/arrowleft/arrowright reverse forms. All map to the same key codes as the bare names already in the map. Surfaces in: - 17:12:17 [PressKey] (Google Chrome) downarrow → no scroll - 17:12:36 [PressKey] (Google Chrome) downarrow → no scroll - 17:13:11 [PressKey] (Google Chrome) space → scrolled (sanity check) - 17:13:25 [PressKey] (Google Chrome) uparrow → no scroll
…ichi#674) Closes other half of sonichi#671. The toggle_tasks tool now accepts an optional taskIndex (1-based, by display order). The web-client MutationObserver parses an 'expand:N' / 'collapse:N' suffix on data-task-action and acts on the Nth task instead of all of them. Backward-compatible: bare 'expand' / 'collapse' still toggles all. Repro the bug played out on a phone call 2026-05-13: caller asked "expand only the first one" four times; voice agent had no tool to do it, kept toggling everything. tsc clean.
Follow-up to sonichi#674. The MutationObserver handler at src/web-client.ts:994 used Object.keys(taskMap) for index lookup — that's insertion order. But renderTasks() displays tasks sorted by time DESCENDING (newest first) with done tasks filtered unless showDone. So taskIndex=1 meant "oldest task by insertion" instead of "top of the list." Symptom: voice "expand the first task" → expanded an old completed task the user couldn't see, while the visually-first task stayed collapsed. Caught 2026-05-13 23:59 PT during runtime test. Fix: replicate the same filter+sort sequence renderTasks() uses, then index into that array. Now "first task" = top-of-list.
…log (sonichi#677) Diagnostic gap: the log line printed bare action ('expand'/'collapse') not the with-suffix actionStr ('expand:1'/'collapse:2'). After sonichi#674 added the taskIndex param, log entries like '[ToggleTasks] expand' became ambiguous — couldn't tell if Gemini passed taskIndex or not. Surfaced during 2026-05-13 23:59 PT debug session: per-task expand appeared broken; couldn't determine from log whether the model omitted taskIndex or the parsing failed. 1-line change. No UI change, no schema change.
) Adds visible numeric prefix to each task row in the dashboard task list. Matches the 1-based taskIndex param the toggle_tasks tool accepts, so when the user says "expand task 3" they can see exactly which task that is. Numbers are display-order based (newest first, filtered same as renderTasks) — the same sequence the toggle_tasks handler uses. Per Chi 2026-05-14 01:26 PT: numbering makes per-task expand targets unambiguous. Does NOT include the exclusivity change from PR sonichi#678 (closed unmerged) — expand:N stays additive.
…-block (sonichi#680) sonichi#679 shipped numbering but it was too muted to notice visually (#6b7a90 / 14px / normal weight against the dark bg). Chi reported the span EXISTS in DOM via DevTools but doesn't see it in UI. Tweaks: #9aa8c0 (lighter slate) / 15px / 600 weight / display inline-block. Same min-width treatment, still left-aligned in the flex row.
…onichi#681) Earlier attempts (PR sonichi#679 numbering, sonichi#680 visibility bump) shipped a separate <span class="task-num"> as a flex item. DevTools confirmed: all 30 .task-num elements had zero width despite min-width:28px, flex-shrink:0, and content "1.". Parent was correctly flex. The flex layout was collapsing the span — likely interaction between .task-text's flex:1 (flex-basis:0) and min-content sizing on the ancestor. Adding !important wider styles didn't help — Chi reported no yellow box appeared. Sidestep entirely: prepend the index INTO .task-text's displayText. Same visual effect (`1. [Voice] ...`), one less layout puzzle. CSS rule for .task-num and the standalone span both removed.
…ender path) (sonichi#682) sonichi#681 only patched the primary renderTasks (line 1148). There's a SECOND renderer in the dynamic-region path (line 2280) for the contextual task list — that's the one Chi was actually looking at. Hence "I see 1. in querySelector but not in UI." DevTools `textContent.slice(0,10)` on the visible list returned '[Discord] ' — no numPrefix — confirming the second path was running. This patch adds the same `var numPrefix = (i + 1) + '. ';` + `displayText = numPrefix + ...` to the second renderer. .map() now passes (entry, i) so i is available.
…lapse-all) (sonichi#683) Sequence "collapse all → expand task N" was broken: collapse-all set userCollapsed=true and cleared the set. expand:N then added the target but reset userCollapsed=false. On the next 3s API poll, the auto-expand block at line ~1212 saw userCollapsed=false and re-added all working tasks → "expand:N" looked like a no-op because everything ended up expanded again. Fix: don't touch userCollapsed in the per-task expand path. If the user just collapsed all, that intent stays sticky; expand:N adds ONLY the target. If userCollapsed is false (no prior collapse), auto-expand keeps working as before.
…dback (sonichi#684) Per Chi 2026-05-14 02:55 PT: voice "expand task N" was confusing because — even when the target task DID land in expandedTasks — the visible effect depended on whether the task had a result body. New tasks without results showed no change. Plus index-shift between voice utterance and command dispatch made it unclear which task got the expand. Adds a 1s background-color flash (rgba(255, 220, 100, 0.45) → transparent) on every DOM element with `data-taskid="<targetId>"` when expand:N successfully resolves. Both render paths (primary renderTasks at #tasks AND dynamic-region at #dr-content) get the class. Force reflow on re-trigger so repeat expand:N still flashes. 50ms delay before applying lets renderTasks() paint the post-add DOM first.
`void (el as HTMLElement).offsetWidth` was inside an inline-JS string-literal that's served verbatim to the browser, never compiled through tsc. The `as HTMLElement` cast leaked through and produced `Uncaught SyntaxError: Unexpected identifier 'as'` at runtime, breaking the entire web UI. At runtime querySelectorAll returns HTMLElement-typed nodes implicitly, so the cast is unnecessary — just drop it. Per feedback_inline_js_escaping.md: inline-JS in web-client.ts template strings is NOT TypeScript, validate as plain JS. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…and:N hit working tasks (sonichi#686) Both renderers (primary line 1150, dynamic-region line 2319) only attached `data-taskid` when `hasResult` was true. For working tasks (no result yet), the wrapper had no data-taskid — so: - the 1s yellow flash from PR sonichi#684 silently no-op'd (querySelectorAll `[data-taskid="..."]` matched nothing) - the `.expanded` class still got added to .task-text, but that only widens long titles via `white-space: normal`; short titles looked unchanged Fix: - Always emit `data-taskid` on the .task-item wrapper, in both renderers. - Add `data-clickable="1"` (only when hasResult) as the separate flag the global click handler uses — preserves "working tasks aren't clickable" behavior without coupling it to the flash target. Self-verified via Chrome MCP: - 30/30 .task-item have data-taskid - expand:3 → target's .task-text gets .expanded AND .task-flash applied - Click handler still only fires for clickable items. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ually sees (sonichi#687) When voice said "expand task N", the MutationObserver computed `ids[N-1]` from a filter+sort that diverges from what's visible: - The primary `#tasks` container is `display:none` — never visible. - The visible task list is the dr-content "tasks" sub-tab, line 2308: `entries.sort(time desc).slice(0, 10)` — NO filter, includes done. - The observer used `filter(non-done).sort(time desc)` — NO slice, all ~hundreds of non-done tasks in time order. When older non-done tasks (like timeouts that hung around in taskMap for days) outranked recent done tasks in the observer's view, voice "task 3" hit something the user couldn't see — Chi caught it targeting a 3-day-old timeout task already in expandedTasks. Fix: split observer's id computation into `visibleIds` (for per-task ops, matching dr-content's top-10 no-filter ordering exactly) and `allIds` (for all-tasks ops, keeping the broader filtered list so "expand all" reaches everything non-done). Self-verified: `node --check` on served JS clean after kickstart. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(start-cli): enable tmux mouse-scroll by default New users (Qingyun 2026-05-13) hit a confusing UX issue: in the Sutando core tmux pane, Up-arrow goes to Claude Code's readline history, not to prior agent output. Reviewing what the agent just did required learning tmux copy-mode (Ctrl-b [) — friction enough to make people give up. Fix: set `-g mouse on` on the tmux server before the session attaches, so two-finger scrollback works out of the box. Existing tmux configs are unaffected (we only touch the server tied to the sutando socket). Idempotent: re-running on an already-configured server is a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(start-cli): note the mouse-on / Cmd-drag tradeoff + Option-drag workaround Per MacBook's PR sonichi#688 review note. With mouse-on, native Cmd+drag text selection is intercepted by tmux. Option+drag bypasses tmux and gives the native selection back. Documenting in the script comment so future readers don't think it's a regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.hsd1.wa.comcast.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/archive (sonichi#653) * feat(sutando-app): add --emit-task flag to runHealthCheck Sutando.app's menu-bar Timer was firing health-check.py with --fix only, so failures it can't auto-fix went silent. The --emit-task flag (already implemented in health-check.py with 1h cooldown per failure-set hash) writes tasks/task-health-{ts}.txt for the bridge to pick up as owner-tier tasks. Gives the trio's `scan_health_anomalies` surface_owner path a redundant peer in the file-bridge layer, so failures the trio suppresses by cooldown (or ACT's LLM step archives by judgment) still reach the agent. Per Chi 2026-05-07 PT after observing 3 stale services (voice-agent, web-client, sutando-app) silently suppressed in trio cooldown all day. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): --emit-task surfaces residual failures, not pre-fix set Per Mini's PR sonichi#640 v1 review (verified): the original code emitted the task BEFORE the --fix loop, so `--fix --emit-task` together meant "emit pre-fix failures, then fix some". Created owner tasks for failures resolved in the same invocation. This commit moves emit_task_for_failures to AFTER the fix loop, and when --fix ran, re-runs checks to capture the residual state. The 2s delay between fix loop and re-check matches the existing per-service sleep(1) restart budget. Behavior matrix: --emit-task only → emit on initial checks (= residual, no fix) --emit-task + --fix → re-run checks after fix loop, emit on residual --notify-on-fail → unchanged: notifies on INITIAL set (launchd fallback wants the alert immediately) Achieves PR sonichi#640's stated contract ("redundant peer for failures the trio suppresses") rather than the noisy pre-fix variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): split emit-task logic by --fix state to restore --quiet coverage PR sonichi#640 v2 (commit f9a88e4) moved the emit-task block to end-of-main so it'd surface the post-fix residual set. Mini caught a regression: the launchd fallback's `--quiet --emit-task --notify-on-fail` invocation bypassed the moved emit via the quiet-path `sys.exit(1)` before reaching end-of-main. So no-fix callers stopped emitting tasks at all. Fix: split by --fix state. - `--emit-task` without `--fix` → emit immediately (initial check IS the residual; placed BEFORE --json / --quiet early exits) - `--emit-task` with `--fix` → emit at end-of-main on residual after fix loop (re-runs checks) Both paths reach emit. No-fix path no longer bypassed by --quiet exit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(discord-bridge): notify agent-api on dm-fallback delivery + archive task/result files Two related fixes for the dm-fallback path (`poll_dm_fallback`): 1. POST to localhost:7843/task-done after dm-result.py succeeds, mirroring voice-agent's task-bridge.ts:533 path. Without this, web UI has a ~5s lag flipping the task status to "done" (waiting for agent-api's next /tasks/active poll). Best-effort; silent on failure. 2. Archive task + result files in all three dm-fallback exit paths (drop-empty / drop-stale / sent-via-dm). Previously, only the result file was unlinked, leaving orphan task files in tasks/ that never got archived. Mirrors the parity of the main reply path. This addresses the systemic missed-replies pattern surfaced by today's user-behavior probe (187 historical owner-tier orphans in 7d archive without matching results). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mpts (sonichi#656) Team/other-tier tasks routed to codex sandbox now get a preamble that tells codex it's answering AS Sutando, not as its own sandbox runtime. Without this, identity/capability questions on public channels get misframed answers — codex lists its own skills (imagegen, github, gmail, google-calendar etc.) instead of Sutando's actual skills. Caught 2026-05-11 on @sirentropy7075's "what skills do you already have?" in AG2 #ag2 channel. Owner-tier tasks don't go through codex (per CLAUDE.md "Discord access control"), so preamble is N/A there. Bridge restart required to pick up the change. Co-authored-by: Chi <wangchi@Chis-Mac-mini.local>
…ivate-repo gating) (sonichi#659) Previously the `sync-memory.sh` script only existed in the private `sutando-memory` repo, which meant external users (or new fleet members) couldn't bootstrap multi-machine sync without first being granted access to that private repo. Chicken-and-egg. This PR: - Adds `scripts/sync-memory.sh` to the public sutando repo, generalized to be vendor-neutral. Reads `SUTANDO_MEMORY_REPO` from `.env`; falls back to `~/.sutando-memory-sync/` by default. Script auto-clones the target repo on first run. New `SUTANDO_MEMORY_SYNC_DIR` env var lets power users override the local clone path. - Adds `docs/memory-sync.md` with architecture overview, setup steps, env-var reference, conflict model, and troubleshooting. - Adds one row to the README's "Optional integrations" table pointing at the new docs. No behavior change for existing users — the script in this PR is the same logic as the one already running in private `sutando-memory`, just generalized for distribution. Anyone in the fleet can now `git pull` and start using their own private memory repo. Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… markers (sonichi#689) The task-bridge already supports a `[deduped: task-<other-id>]` result-body marker (`src/task-bridge.ts:527`) that silently archives a task — no voice narration, no Discord DM. Discord-bridge also recognises `[no-send]` and `[REPLIED]` (`src/discord-bridge.py:2601`). None of these are documented in CLAUDE.md or any skill prompt; the only marker the docs mentioned was `[file:` for attachments. Practical cost: in a 2026-05-14 voice session the agent processed 13 tasks across 3 continuation threads, wrote stub "(see other reply)" results for the superseded tasks, and the bridges played all 13 replies — produced an interrupt cascade (turns 18–28 in `logs/voice-agent.log`) and the owner ended the session. The `[deduped: ...]` path would have silenced 10 of those. Changes: - CLAUDE.md `## Task bridge`: new "Result-body protocol markers" block listing `[deduped]`, `[no-send]`, `[REPLIED]`, and the file-attach trio. - `skills/proactive-loop/SKILL.md` step 1: thread-consolidation guidance pointing at the dedup marker, cross-linking the full list in CLAUDE.md. No code change. No behaviour change. Documenting an existing protocol so future passes use it on the first try. Co-authored-by: xyz <xyz@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#870) (sonichi#876) Per workspace-design RFC sonichi#858 (Decision 2), the env var for the Memory space is renamed from `SUTANDO_PRIVATE_DIR` to `SUTANDO_MEMORY_DIR` so the vocabulary matches the 3-space mental model (Code / State / Memory). One-release alias period: - The new name `SUTANDO_MEMORY_DIR` is the canonical lookup. - The old name `SUTANDO_PRIVATE_DIR` continues to work as a fallback. - A deprecation warning fires to stderr on every read of the legacy var (not just at startup — cron / launchd environments would otherwise miss startup-only warnings and silently sit on the legacy name). Resolution helper: - Python: `_memory_dir_env()` in src/util_paths.py. - TypeScript: `memoryDirEnv()` exported from src/util_paths.ts. - Both: prefer the new var, fall back to the legacy one with the warning. Call-site updates: - src/util_paths.py — `_private_machine_dir()` + `shared_personal_path()` route through the helper. - src/util_paths.ts — `personalPath()` + `sharedPersonalPath()` route through the helper. - src/inline-tools.ts — `loadSkillManifestTools()` + `loadCoreDocumentedSkills()` + open_file doc text. - src/voice-agent.ts — voice-context loader. - skills/phone-conversation/scripts/conversation-server.ts — inline `personalPath()` twin. - src/session-handoff.sh — passes both env vars through to the python child. - Docs: CLAUDE.md, docs/workspace-contract.md, skills/MANIFEST.md. Tests: 10 Python + 5 TS unit tests covering precedence, legacy fallback, deprecation warning, and the every-read guard. Zero existing-user breakage: anyone with only `SUTANDO_PRIVATE_DIR` set keeps working through the deprecation window, sees the warning, and can migrate by renaming the env var. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…owed (closes sonichi#811) (sonichi#814) * test(telegram-bridge): behavioral tests for TOFU + tri-state load_allowed (closes sonichi#811) Follow-up tests for PR sonichi#808's TOFU work + PR sonichi#813's chmod fix. Pins the load-bearing tri-state distinction in load_allowed() so a future refactor can't silently regress to the silent-drop bug: None → access.json doesn't exist → TOFU-eligible set() → file malformed (fail-closed) OR explicitly empty allowFrom → never TOFU set([ids]) → normal allow check Uses importlib.util to load src/telegram-bridge.py (hyphenated filename) as the 'telegram_bridge' module, monkey-patching ACCESS_FILE to a per-test tmpdir. Module-level `if __name__ == '__main__'` guard means the polling loop does NOT start on import. Eight tests covering issue sonichi#811's acceptance criteria (a)–(f) + the chmod 0o600 security guard from sonichi#810: - test_load_allowed_missing_file_returns_none (f) - test_load_allowed_malformed_returns_empty_set (b, f) - test_load_allowed_empty_allowlist_returns_empty_set (c, f) - test_load_allowed_populated_allowlist_returns_set (d, f) - test_tofu_onboard_writes_file_with_payload (a) - test_tofu_onboard_handles_missing_username - test_tofu_onboard_race_safety_does_not_clobber (e) - test_tofu_onboard_chmod_600 (sonichi#810) All 8 tests pass: $ python3 tests/telegram-bridge-tofu.test.py PASS: test_load_allowed_missing_file_returns_none PASS: test_load_allowed_malformed_returns_empty_set PASS: test_load_allowed_empty_allowlist_returns_empty_set PASS: test_load_allowed_populated_allowlist_returns_set PASS: test_tofu_onboard_writes_file_with_payload PASS: test_tofu_onboard_handles_missing_username PASS: test_tofu_onboard_race_safety_does_not_clobber PASS: test_tofu_onboard_chmod_600 All 8 tests passed * fix(test): set TELEGRAM_BOT_TOKEN before module load in tofu test The new behavioral test in tests/telegram-bridge-tofu.test.py was failing in CI with `TELEGRAM_BOT_TOKEN not set` exit code 1. Root cause: src/telegram-bridge.py calls `exit(1)` at module level when TELEGRAM_BOT_TOKEN is unset, before any test function runs. CI doesn't ship a real Telegram token (deliberate — the bridge isn't installed in CI), so the test process dies during importlib's exec_module step. Fix: set a placeholder env var inside load_bridge_module() BEFORE the exec_module call. The placeholder is never used at runtime because the tests only exercise access-control helpers (no Telegram API calls). Same pattern slack-bridge-allowlist.test.py uses for slack_bolt. Verified locally: `unset TELEGRAM_BOT_TOKEN; python3 tests/telegram-bridge-tofu.test.py` now passes all 8 cases. Full `npm run test:py` clean (27 tests pass).
…pe-race in sonichi#899 (sonichi#901) sonichi#899 reports `~/.claude/channels/slack/access.json` getting wiped and re-TOFU'd despite the race-guard in `tofu_onboard()` (`if ACCESS_FILE.exists(): return ...`). Searched the whole codebase + skills dir — only `slack-bridge.py` writes to that file, and no code deletes it. So the wipe is happening externally (Sutando.app Settings UI, manual `rm`, or a code path I missed). The only way to catch the deleter is to log the file's state every time `_write_task` runs. This adds one line per event: [access-probe] file_present=<bool> mtime=<float|None> When the next wipe fires, the log will show `file_present=False` at the event timestamp — bracketing the deletion to a ~5-second window between events. That's enough to correlate against `fs_usage` or `inotify` captures, or against owner activity (Settings UI click, etc). Cost: one print per inbound Slack event. Bridges are already log-noisy per event (`Wrote task ...`, `Replied to ...`), so the marginal noise is negligible. Failure-safe: wrapped in try/except so a probe error won't block real work. Origin: sonichi#899 "next-step instrumentation" section, option 1.
…sonichi#771) * feat(whatsapp): formalize wacli as a skill (skills/whatsapp/SKILL.md) Re-applied on top of current main after the original branch tip drifted behind by ~60 commits. Original commit `3414b3f` touched `CLAUDE.md`'s inline "Built-in capabilities" section; since that section has been moved to `docs/built-in-tools.md` (host-CLI bindings refactor), the WhatsApp pointer + env-var note is applied there instead. Synthesizes the salvageable parts of sonichi#180 (priyansh4320, stale + DIRTY since 2026-04-05) and closes the gap that sonichi#179 was originally asking for. WhatsApp via wacli has been doc'd inline since the wacli skill landed, but no `skills/whatsapp/` directory or proper SKILL.md existed. What this PR adds: - `skills/whatsapp/SKILL.md` with proper frontmatter (`name`, `description`) matching the modern skill pattern (bot2bot-post, schedule-crons, etc.). Documents install + auth, full command list, failure modes, and the confirm-before-send convention. - `.env.example` gets optional `WACLI_DEVICE_LABEL` and `WACLI_DEVICE_PLATFORM` (read by wacli itself; show in WhatsApp → Linked Devices on the user's phone). - `docs/built-in-tools.md` WhatsApp entry now points at the SKILL.md and notes the optional env vars. What was dropped from sonichi#180: - `scripts/verify-whatsapp-skill.sh` — single-skill validator is overkill at this stage; SKILL.md schema validation belongs at the manifest layer (sonichi#742/sonichi#744 omnia-agent.yaml work) if/when it lands. - `package.json` `test` script overload — replacing `test` with a one-skill check would shadow the broader `tests/*.test.ts`/.py infrastructure. Skipped. Closes the wacli-skill story originally opened as sonichi#179 (closed 2026-05-16 noting the capability already existed inline; the SKILL.md formalization is the remaining loose end). * docs(sonichi#771): fold @liususan091219 review items into SKILL.md + .env.example Four small inline followups from the cold review on sonichi#771: 1. 3rd-party-tap caveat — "brew install steipete/tap/wacli" pulls from a non-Homebrew-core tap and the session lives locally at ~/.wacli/. Flagged inline + added a "review the tap before installing if security-sensitive" line so the security trade-off is explicit. 2. WACLI_DEVICE_PLATFORM enum — wacli docs say "Defaults to DESKTOP; invalid values fall back to CHROME." The full enum isn't enumerated in their README, so the SKILL.md links out instead of asserting a list (would have drifted otherwise). Default corrected: was CHROME in the previous draft, actually DESKTOP per upstream. 3. .env.example trailing-newline — added the missing \n the previous draft inherited from the pre-edit file. 4. Confirm-before-send convention — clarified the convention lives in the calling agent / bridge, not in the skill itself (the SKILL.md has no confirm tool). A future contributor reading just the skill doc won't assume the skill handles the check.
…indings.md (closes sonichi#875) (sonichi#878) Follow-up from PR sonichi#858 review (Susan / Mac Studio non-blocking observation). RFC sonichi#858's "Host CLI dependency surface" section gated the engineering inventory behind "ask the maintainer for the workspace-private note," which adds friction for the contributors most likely to be useful — the host-CLI-aware ones. Splits the public-friendly taxonomy out into docs/host-cli-bindings.md: - 4-row surface table: agent memory, skill discovery, bridge tokens, slash commands — each with what binds it + per-surface re-bind cost on a host-CLI swap (high / low / interop-loss). - Canonical greppable helper (`claude_home_path()` / `claudeHomePath()`) named with a usage policy. - Explicit no-specific-paths rule (those stay in the workspace-private engineering note to avoid bit-rot when host-CLI conventions change). - "Why this matters" framing on the dependency-surface trade. Trims `docs/workspace-design.md`'s "Host CLI dependency surface" section from ~15 lines to a 2-line pointer at the new doc. Adds it to the "Relationship to other docs" list so contributors land on it when reading the design RFC end-to-end. No code change; no path strings published. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…in /presenter endpoint (closes sonichi#790) (sonichi#804) The web-client polled http://localhost:7877/presenter every 5s — a port belonging to the iclr-highlight skill which is not in this repo. On stock installs this produced ERR_CONNECTION_REFUSED + browser CORS console noise every 5 seconds with no way to suppress it (browser logs the failed fetch before user JS runs). Replace with a same-origin /presenter route backed by the same state/presenter-mode.sentinel that check-pending-questions.py, discord-bridge.py, and telegram-bridge.py already consume. The route applies the same malformed-sentinel guard (first char must be a digit → fail CLOSED on garbage). window._PRESENTER_URL still overrides the default for installs that run a custom presenter-state server. Side-effect: presenter-mode badge now "just works" in the web UI on any install without requiring an external skill server. Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…pture fallback (sonichi#902) Folds three changes onto the ax-read subprocess approach (PR sonichi#891 groundwork) to make ⌃C reliable across rebuilds: 1. **AXIsProcessTrustedWithOptions(prompt:true) at startup.** When Sutando.app's codesign identity changes (ad-hoc → cert, rename, rotate), the prior TCC Accessibility grant orphans silently — macOS returns AX errors without re-prompting. Empirical case 2026-05-19: Sutando.app was re-signed; AXIsProcessTrusted=false on first launch despite the prior grant still appearing toggled-on in System Settings. Adding the prompt call at startup forces macOS to re-bind the entry to the current binary on first AX use, or pop the standard "Sutando wants Accessibility" dialog if no matching entry exists. Idempotent when already trusted. Result logged so future drift is grep-able from session 0. 2. **Flip dropContext priority: ax-read FIRST, clipboard image second.** Original order checked clipboard PNG/TIFF BEFORE attempting AX read. Result: any lingering screenshot in the clipboard pre-empted text- selection drops, so ⌃C in Discord with a selection would silently drop yesterday's screenshot instead. The dominant ⌃C use case is "drop the highlighted text" — so ax-read runs first; only when it returns empty (`path=none`) do we fall through to the clipboard image branches. 3. **screencapture -i -c last-resort fallback.** When ax-read returns empty AND clipboard has no image AND legacy AX returns empty AND simulated Cmd+C doesn't advance pasteboard changeCount, instead of giving up with "Nothing selected" we now launch `screencapture -i -c`. macOS draws the crosshair, user drag-selects a region (or Esc to cancel). On completion the resulting PNG/TIFF clipboard content is captured as an image drop. Per Chi 2026-05-19: every ⌃C now has SOME path to producing a drop instead of dead-ending at "Nothing selected." Also requires (out-of-band, not in this PR) codesigning Sutando.app with an embedded designated requirement so TCC keys on bundle identifier alone, surviving cdhash churn across rebuilds: codesign --requirements '=designated => identifier "com.sutando.menubar"' Verified: rebuilt binary with different cdhash + same identifier passes `AXIsProcessTrusted=true` on first run after one initial grant. Tested locally: - ⌃C with text selected in Discord → drops via ax-read (path=ax) - ⌃C with image in clipboard, text selected → text wins (was: image won) - ⌃C with nothing selected anywhere → screencapture crosshair fires - Esc during screencapture → "Cancelled — nothing dropped" Supersedes previous proxy-via-web-client approach on this branch. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sonichi#905) * feat(telegram-bridge): preserve forward-from attribution in task body When the owner forwards a Telegram message into the bridge (third party's words forwarded by the owner), the prior task line read `[Telegram @owner] <forwarded words>` — the original sender's name silently disappeared. Future readers of the task can't tell the owner's words from forwarded. Parse Telegram Bot API 7.0+'s `forward_origin` (covers user, hidden_user, chat, channel forwards) plus the legacy `forward_from` / `forward_sender_name` fallback for older responses. Append `[forwarded from <name>]` to the task header when present. Behavior shift on read: before: task: [Telegram @owner] <forwarded words> after: task: [Telegram @owner [forwarded from @other]] <forwarded words> Sender (for access-list check) is unchanged — still the forwarder, since that's who's authorized to drop messages into the bridge. Attribution only affects what the agent reads. * test(telegram-bridge): pin forward-attribution surface (9 cases) Extract the inline forward-origin parsing block into a module-level `extract_forward_note(msg)` helper and add behavioral tests covering every documented Bot API 7.0+ origin type plus the legacy fields. Cases pinned: - non-forwarded msg → "" - forward_origin user (username, first-name fallback) - forward_origin hidden_user - forward_origin channel - forward_origin chat - legacy forward_from - legacy forward_sender_name - unknown forward_origin.type → "" (fail-open, no crash) Pure refactor — task header output is byte-identical for all paths. 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#904) * fix(bridges): team/other codex output via staging path + atomic move The current team/other tier system-instructions inject: codex exec --sandbox read-only -o {RESULTS_DIR}/task-{id}.txt -- ... …and tell the agent: "if codex exits non-zero OR the output file does not appear after the command finishes, treat as a failure and apply the fallback rule." That check is racy. Sequence observed today: 1. Codex completes, writes results/task-{id}.txt (exit 0). 2. Bridge polls results/, picks the file up, delivers to channel, moves to results/archive/. 3. Agent's post-exec `ls results/task-{id}.txt` happens AFTER step 2, sees "no such file," writes the 'Sandbox unavailable; refusing non-owner task.' fallback to results/task-{id}.txt. 4. Bridge delivers that second message → user receives both the real codex reply AND the spurious fallback. Caught it after Lucy's PR-skill design question (full codex review delivered, then a "Sandbox unavailable" follow-up at ~10s) and MacBook's PR sonichi#902 review ping (same pattern, real review at 19:53 then fallback ~10s later in #bot2bot-trio). Fix: rewrite both team-tier and other-tier system-instructions to use a two-stage flow: Stage 1: codex exec ... -o /tmp/sutando-codex-staging-{id}.txt -- ... Stage 2: if codex exit 0 AND staging non-empty: mv /tmp/sutando-codex-staging-{id}.txt {RESULTS_DIR}/task-{id}.txt else: write 'Sandbox unavailable; refusing non-owner task.' to {RESULTS_DIR}/task-{id}.txt directly. The bridge poller only ever sees the final file (post-mv) — no in-flight half-written results, no phantom-missing race. The two existing failure modes (codex non-zero exit, codex produced no output) collapse into one "empty/missing staging file" check evaluated BEFORE the bridge ever sees anything in results/. No bridge-side code changes; the poller and archive logic are unchanged. The only diff is the system-instructions template injected into team/other tier task files. * fix(bridges): same-fs staging path for guaranteed atomic mv Per qingyun-sutando review note #2 on sonichi#904: staging in /tmp/ + mv into {RESULTS_DIR}/ is only atomic when both share a filesystem. Today both live on the system disk so this holds. Future-proof if anyone moves $SUTANDO_WORKSPACE to an external volume: stage to {RESULTS_DIR}/.codex-staging-{id}.txt instead. Same-filesystem mv is guaranteed atomic regardless of workspace mount point. Leading dot keeps the staging file out of the results-poll glob (bridge filters by 'task-' / 'proactive-' prefix; .codex-staging-* doesn't match either). --------- Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
discord-bridge.py and telegram-bridge.py wrote the task `timestamp:` field with `time.strftime(...)` (local time) plus a literal `Z` suffix claiming UTC. On a non-UTC host every task timestamp was off by the host's UTC offset, breaking any staleness computation that reads it. Pass `time.gmtime()` so the value is genuinely UTC. Matches the already-correct `now_iso` at telegram-bridge.py:154. Closes sonichi#908 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the workspace-vs-repo path mismatch that surfaced as false-positive
health-check warnings (voice-agent.log "not found" at /workspace/logs while
the actual file was written to /repo/logs by startup.sh).
Behavior change:
- Add $WORKSPACE resolution at top of service-start block, mirroring
src/workspace_default.py / .ts: env override `$SUTANDO_WORKSPACE`,
fallback to `~/.sutando/workspace/`.
- All 8 service log redirections (voice-agent, web-client, dashboard,
agent-api, screen-capture, telegram-bridge, discord-bridge,
slack-bridge) now write to `"$LOGS_DIR/$name.log"` instead of the
CWD-relative `logs/$name.log`.
- mkdir -p $WORKSPACE/{logs,tasks,results,data} so the redirections
don't fail on a fresh install.
- Diagnostic-failure echo also points at $LOGS_DIR.
- Legacy repo-root `tasks/ results/ data/` mkdir kept for now —
back-compat with any script that still resolves CWD-relative paths.
Why this matters: health-check.py + dashboard already look at
$WORKSPACE/logs (per PR sonichi#821/sonichi#859 workspace-design contract). Without
this change they ran on a stale subset of services every cycle and
emitted false "log not found" warnings that masked real issues.
No code changes inside the services — they emit to stdout/stderr and
startup.sh redirects, so this is a one-file shell-only diff. The
services never opened log files directly, which is why this didn't
need to ride along with sonichi#821's TS migration.
Tested: smoke-checked resolution snippet — `$LOGS_DIR` resolves to
`/Users/wangchi/.sutando/workspace/logs` (existing dir with current
logs from prior bridges). `bash -n` clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ichi#912) The README's Sutando WIRE list was about to hit 8 entries (ep001-ep008) and growing. Trim to the most recent 3 and link the rest to the existing playlist URL (already referenced on the next line as the canonical landing page). Older episodes remain one click away via the playlist. Recent capability proofs list left as-is (5 items; not crowding yet). Re-apply the same pattern there once it hits ~8. Pattern picked over the "move to docs/ sub-page" alternative because the playlist already exists and YouTube renders it well; an in-repo sub-page would be a parallel source of truth for the same data. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oot (sonichi#913) * feat(init.sh): create runtime dirs/files under \$WORKSPACE, not repo root Closes the breadcrumb loop Mini flagged in sonichi#911 review: startup.sh now redirects service logs to \$WORKSPACE/logs/, but init.sh was still mkdir-ing repo-root logs/state/tasks/results/notes/data/ + writing pending-questions.md, contextual-chips.json, core-status.json, voice-state.json into the repo. So even with sonichi#911 merged, every startup.sh run re-created stale repo-root breadcrumbs. This PR makes init.sh workspace-aware: - Resolve \$WORKSPACE at top (same shape as startup.sh, workspace_default.py) - create_file_if_missing / create_dir_if_missing now write under \$WORKSPACE - copy_if_missing kept repo-rooted — it's used today only for skills/schedule-crons/crons.json which lives in the repo by design Smoke-tested: `bash src/init.sh --auto` creates the previously-missing \$WORKSPACE/data/ + \$WORKSPACE/pending-questions.md cleanly. Existing workspace files left alone (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(init.sh): migrate stale repo-root runtime state to \$WORKSPACE on first run Closes the gap Susan flagged in PR sonichi#913 review: after sonichi#911 (workspace-aware service logs), every install pre-dating that PR keeps two copies of runtime state — stale repo-root `logs/`, `state/`, `pending-questions.md`, `core-status.json` alongside the new workspace versions. git status pollution + future debugging confusion. This adds a one-time migration function modeled on `workspace_default.py::_migrate_from_legacy` (PR sonichi#762): - Fires only when `\$WORKSPACE/.legacy-migrated-911` sentinel is absent - Scans `\$REPO/` for evidence (any of logs/state/tasks/results/notes/data non-empty); skips entirely on fresh installs - Moves each runtime dir + file `\$REPO/X` → `\$WORKSPACE/X` ONLY when the workspace destination doesn't already exist (non-destructive on collision) - Writes sentinel after the sweep; second run sees it and bails (zero log noise on every subsequent startup) - Stderr line per moved item so users can audit what happened Files moved: dirs `logs/`, `state/`, `tasks/`, `results/`, `notes/`, `data/`; files `pending-questions.md`, `core-status.json`, `contextual-chips.json`, `voice-state.json`, `build_log.md`. Matches the set sonichi#911 + earlier sonichi#821/sonichi#859 contract migrated. Smoke-tested locally with a synthetic test repo + workspace: - First run: migrated 4 fake stale files (logs/, state/, notes/, core-status.json) from /tmp/sutando-migration-test → /tmp/test-workspace with one stderr line each. Sentinel written. - Second run: silent (sentinel present, bails immediately). 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#914) Follow-up #2 to PR sonichi#911 (per Mini's review). The launchd-supervised health-check fallback job still wrote its log to \$REPO/logs/ via the plist's `__REPO__/logs/health-check-launchd.log` substitution. Aligns with the workspace-vs-repo split landed in sonichi#911. 3-file diff (matches what Mini said the scope must be — plist alone would break since the install script does the sed substitution): - `src/launchd/com.sutando.health-check-fallback.plist`: change `StandardOutPath` + `StandardErrorPath` from `__REPO__/logs/` to `__WORKSPACE__/logs/`. Header comment updated to reference \$WORKSPACE. - `src/install-health-check-launchd.sh`: resolve \$WORKSPACE at top (env override + ~/.sutando/workspace fallback), `mkdir -p \$WORKSPACE/logs` instead of `\$REPO/logs`, add `__WORKSPACE__` to the sed substitutions. Re-installation is idempotent (`bash src/install-health-check-launchd.sh` bootouts + bootstraps the new plist), so existing installs just need a re-run to pick up the new log path. Smoke-tested: dry-rendered the plist with WORKSPACE=\$HOME/.sutando/ workspace — StandardOutPath / StandardErrorPath both correctly resolved to /Users/wangchi/.sutando/workspace/logs/health-check-launchd.log. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to sonichi#909. Closes the two loose ends from the local-time- mislabeled-as-UTC timestamp bug: - src/slack-bridge.py:359 had the same bug sonichi#909 fixed in discord/ telegram bridges — the task `timestamp:` field written as local time with a literal `Z`. Fixed identically via time.gmtime(). - scripts/check-utc-z-strftime.py: an AST gate that flags single-arg time.strftime() with an ISO-datetime format mislabeled as UTC (`Z`). Wired as a CI step — no stock linter catches this (Ruff DTZ covers datetime, not time.strftime). Precise: requires the `Z` mislabel, so innocent local-time console-log timestamps are not flagged. Closes sonichi#916 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#907) * feat(context-drop): public ax-read binary so non-private installs get ⌃C selection drops Sutando.app's ⌃C drop-context handler spawns an `ax-read` CLI subprocess to read the focused app's text selection via the macOS Accessibility API. Before this change the binary lived only in the private `personal-deictic` skill (memory-sync), so public-repo users without that skill installed had no way to use the AX path — the in-process legacy fallback runs but is known-broken for Electron apps under Sutando.app's LSUIElement=YES process context, so those users would silently get "Nothing selected" on every ⌃C in Discord / Slack / Cursor / VS Code. This adds a public, text-only `ax-read` under `skills/context-drop/`: - `Package.swift` — SPM, single `ax-read` executable target - `Sources/ax-read/main.swift` — text-only AX read (no screenshot, no cursor; those stay in the private `personal-deictic` package since they're deictic-edit-mode specific) - `build.sh` — wraps `swift build -c release` and copies the artifact to `skills/context-drop/ax-read` - `SKILL.md` — usage + relationship to the private skill `main.swift::resolveAxReadPath` now tries the public binary as a 4th fallback after the 3 private paths. Users with `personal-deictic` installed keep their richer (screenshot + cursor) capture; public-repo users get the text-only path and the same ⌃C experience for selections. `src/startup.sh` auto-builds the public binary when missing or stale, right before the Sutando.app compile step. Idempotent. Tested locally: temporarily renamed the private binary; ⌃C in Discord with text selected landed via the public `ax-read` at `ax_path: ax` — JSON shape matches the private one for the fields Sutando.app reads (`app`, `window_title`, `url`, `selected`, `path`). Private binary restored after the test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(context-drop): preserve full pasteboard + fail loud on missing swift (per Mini sonichi#907 review) Mini flagged two blocking issues + two nits on PR sonichi#907's first commit: 1. **Pasteboard corruption (BLOCKING)** — ax-read's Cmd+C fallback only snapshotted `pb.string(forType: .string)` and restored that on completion. When the clipboard held a non-string item (image, file URL, custom UTI), the post-Cmd+C `clearContents()` + `setString(prior)` rewrote the pasteboard with just the string view, losing the original data. Since Sutando.app's dropContext invokes ax-read BEFORE the clipboard image branches, public installs would silently lose images-in-clipboard before the image-drop branches got a chance to handle them. Fix: snapshot the full `(type, data)` set across every existing `pasteboardItems` entry, and rebuild from that snapshot only when Cmd+C actually dirtied the pasteboard. If changeCount didn't advance we skip the clear/rewrite entirely — no churn on the user's clipboard. 2. **Silent build failures (BLOCKING)** — `src/startup.sh` was wrapping the public ax-read build in `>/dev/null 2>&1`. When Swift was missing or the build broke, the script swallowed stderr and continued into the legacy AX path — the exact silent-failure class this PR is meant to prevent. Fix: guard with `command -v swift`, surface build output (no `>/dev/null`), and print explicit diagnostic on missing toolchain. 3. **Staleness check too narrow (nit)** — only checked `Sources/ax-read/main.swift`. Now uses `find` over `Sources/`, `Package.swift`, `build.sh` and triggers a rebuild on any change. 4. **SKILL.md wording (nit)** — replaced "identical output" with "shared text-selection subset" to reduce contract drift with the private `personal-deictic` package; explicitly named the five-field subset Sutando.app reads vs the deictic-specific fields private adds. Tested locally: - Set a TIFF on the pasteboard, ran ax-read with no selection in focused app → output `path:none`, pasteboard `items=1, type=public.tiff` still intact (changeCount unchanged). - `bash build.sh` succeeds after edits; binary smoke-runs against Discord. 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>
Adds a nightly cron + on-demand workflow that fetches the Sutando WIRE playlist via YouTube Data API v3, ranks videos, dedups across slots, and substitutes the rendered block between `<!-- wire-list:start -->` / `<!-- wire-list:end -->` markers in README.md. Slot layout (Chi's "(C) with hard de-dup" pick): - 2 newest by publishedAt - 2 hero by likeCount (excluding videos already in newest; >=30 days old to avoid rotation-lag double-listing) - 1 "Earlier episodes →" playlist link A video appears in at most one slot, ever. Components: - scripts/regen-wire-list.py — fetch + rank + dedup + splice - .github/workflows/regen-wire-list.yml — nightly @ 02:37 UTC + dispatch - docs/regen-wire-list-setup.md — one-time `YOUTUBE_API_KEY` setup - README.md — marker pair added around the WIRE block Workflow opens a PR rather than auto-committing; you stay the final gate. Free tier covers our use case (well under 10k quota units/day). Smoke-tested locally for splice + render-block logic. Real API call needs the secret; first cron-fire after setup will be the integration test. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chi#791) Adopts upstream's sonichi#603 conversation-store — a time-indexed, searchable SQLite mirror written best-effort alongside the text conversation.log (text log stays primary truth; sqlite errors are swallowed). - src/conversation-store.ts (new) — node:sqlite DB helper: conversation, sessions, tool_calls, session_events tables; recordConversation / recordSessionBoundary / recordSession. - task-bridge.ts — logConversation/logSessionBoundary parallel-write the mirror; logConversation gains an optional sessionId. - voice-agent.ts — recordSession replaces the data/voice-metrics.jsonl writer; per-turn logConversation passes SESSION_ID. - conversation-server.ts (phone) — recordSession replaces the call-metrics.jsonl writer; per-call turns mirror via recordConversation. - diagnose.py — reads the sessions table (call-metrics.jsonl no longer fresh); adopted verbatim (fork was identical pre-sonichi#791). - import-conversation-log.py / import-session-metrics.py — one-shot backfill importers. query-conversation.sh — sqlite3 CLI. - engines.node >=22.5.0 (node:sqlite floor). Retired two stale voice-metrics POC scripts. Deferred: the sonichi#791 cross-node-sync doc reframes (land with CP-9) and the init-script.test.ts path fix (belongs with sonichi#913 / CP-11).
node:sqlite is a flagged-experimental builtin until Node 22.13. The
bundled .app runtime is 22.11 — a static `import` of node:sqlite threw
ERR_UNKNOWN_BUILTIN_MODULE at module load, crash-looping the voice-agent
(and taking down the conversation screen) because task-bridge.ts and
voice-agent.ts statically import conversation-store.
Switch to a type-only import + a guarded createRequire('node:sqlite')
inside init(). On a runtime without node:sqlite the mirror disables
itself (initFailed latch, no-op) — the text conversation.log and the
rest of the agent are unaffected. This is what the module's best-effort
contract always intended; a hard static import of an experimental
builtin violated it.
…ne" false alarm /tasks/active reported claude_ok via `pgrep -f "claude.*sutando-core"`, but the core agent is launched as `claude … -- /proactive-loop` inside a tmux session *named* sutando-core (src/run-core-agent.sh). The session name never appears in the claude process args, so the pattern matched nothing and the dashboard always showed "brain offline" even with a healthy core agent. Match `claude.*proactive-loop` — the command the core agent actually runs with.
screen-companion, zoom, discord-voice Adopts six upstream skills. Four are pure new directories; two touch shared core files. Clean adoptions: - deal-finder (sonichi#657), whatsapp (sonichi#771), context-drop (sonichi#902/sonichi#907) - screen-companion (sonichi#794/sonichi#801/sonichi#816) — manifest skill, auto-discovered by loadSkillManifestTools(); imports CP-5's vision-tools. Zoom move (sonichi#827): - new skills/zoom/tools.ts — summon / dismiss / join_zoom relocated out of meeting-tools.ts per the CLAUDE.md inline-tool-location rule - meeting-tools.ts stripped to joinGmeet / lookupMeetingId / callContact - inline-tools.ts re-exports the Zoom trio from the skill Discord voice (sonichi#783): - new skills/discord-voice/ — standalone TS voice-channel server (discord.js + @discordjs/voice + bodhi VoiceSession) + share-screen - package.json: +discord.js, @discordjs/voice, @discordjs/opus, libsodium-wrappers; package-lock regenerated, all deps load (incl. native @discordjs/opus) - browser-tools.ts: switch_tab description disambiguated vs share_screen Typecheck clean, 176 tests pass. README integrations-table tweak from sonichi#783 skipped (fork README diverged).
Adopts upstream's diagnostics + memory-sync work. The fork never independently modified any of these skills (only an old upstream merge touched them), so upstream's versions are adopted wholesale — clean. - self-diagnose (sonichi#768, sonichi#727): cross-node gather over SSH (gather-remote.sh), gh-resolution fix when miniconda shadows it. - cross-node-sync (sonichi#758): MEMORY.md regenerator (regenerate-memory-index.py) auto-called after sync. Also lands the sonichi#791-deferred doc reframes (SKILL.md, merge-voice-metrics.sh, setup-rsync-sync.sh). - sync-memory (sonichi#659, sonichi#764, sonichi#755): scripts/sync-memory.sh — memory+notes rsync; default ~/.sutando/memory-sync/ with legacy auto-migration; .env sourced from workspace root. - docs/memory-sync.md, scripts/stage-readiness.sh. - .gitignore: !tests/*.test.sh so the new shell tests are tracked. npm test still 176 pass. New shell tests (sync-memory-migration, gather-remote) aren't wired into `npm test` (it runs .test.ts + .py only) — sync-memory-migration verified manually, 5/5 pass. README integrations tweak skipped (fork README diverged).
Small isolated fixes across inline/browser tools + skills. inline-tools.ts: - type-text (sonichi#660/sonichi#661/sonichi#664): clipboard-paste for non-ASCII; UTF-8 locale + stdin pbcopy (launchd locale fix); `append` flag. - press_key (sonichi#673): uparrow/downarrow/arrowup/... aliases — bare names fell through to literal keystroke. - cancel-task (sonichi#663): writes a CANCEL_INSTRUCTION task into the pipeline instead of just unlinking; CLAUDE.md gets the matching handler note. browser-tools.ts: - scroll (sonichi#672): amount param (small/medium/large → 150/400/800px). - open_url (sonichi#721/sonichi#919): reuse active tab on same-origin; reject whitespace / zero-width chars early; query-redacted failure logging. skills: - screen-record (sonichi#662/sonichi#667/sonichi#668): mic preference, silent-audio warning, ffmpeg stderr capture — adopted, fork's bundled-ffmpeg (bare `ffmpeg`) re-applied. - tts (sonichi#691): quote-strip in synthesize.sh (openai-tts + gemini-tts). - morning-briefing (sonichi#690): gws calendar instead of removed script. - schedule-crons (sonichi#732): example cron points at scripts/sync-memory.sh. Typecheck clean, 176 tests pass.
…-api First slice of CP-11 (app/startup/init). Heavy CX checkpoint — the self-contained pieces: - watch-tasks (sonichi#693/sonichi#736): retire one-shot watch-tasks.sh; CLAUDE.md documents the Monitor + watch-tasks-stream.sh pattern. sonichi#736 (SUTANDO_WORKSPACE TASKS_DIR) already satisfied — fork's watch-tasks-stream.mjs honors it. - startup.sh (sonichi#698): macOS 15+ Screen Recording denial detection (silent black-PNG → size gate); gate screen-capture launch on PERM_OK. - startup.sh (sonichi#780): git committer attribution from stand-identity.json. sonichi#911 (LOGS_DIR redirect) already done in the fork. - agent-api.py + dashboard.py (sonichi#728): absolute /usr/bin/{pgrep,git,pmset} for launchd-spawnable code paths. sonichi#729 (/logs/voice path) already satisfied — fork uses state_path(). - launchd (sonichi#914): health-check fallback log → $WORKSPACE/logs. - start-cli (sonichi#688): tmux mouse-scroll. schedule-crons SKILL.md, tmux-status test: adopted. Deferred to CP-11 part 2: init.sh sonichi#913 (workspace runtime dirs) + the matching tests/init-script.test.ts fix, main.swift sonichi#694/sonichi#699 (pause-loop submenu, Timer cadence), Info.plist sonichi#807. 176 tests pass.
…tcher cadence Finishes CP-11 of the sonichi/sutando → AG2Platform/stando upstream sync. Applied surgically — the fork diverged from upstream on every touched file. - sonichi#913 init.sh: workspace-rooting was already done in the fork; ported only the missing one-time migrate_legacy_runtime_state() sweep, adapted to the fork's STATE_ROOT + repo/workspace split (notes/, build_log.md, pending-questions.md stay repo-rooted — cross-fleet-shared). - sonichi#694 main.swift: Pause Loop submenu (30m / 1h / indefinite) via shared writePauseSentinel(); new pauseSentinelActive() guard added to the bodies of refreshContextualChips() and runHealthCheck() so paused windows also skip startup one-shot fires. - sonichi#699 main.swift: checkWatcher Timer 30s → 300s; dropped the now-dead 120s lastWatcherAlert throttle. - sonichi#807: no change needed — app/Info.plist already declares NSAppleEventsUsageDescription and build-app.sh seals it at codesign. - tests/init-script.test.ts: adopt a separate .workspace dir (closes the deferred sonichi#791 test fix) + 3 new legacy-migration tests. Verified: bash -n + swiftc -parse clean; npm test 179/179 pass; init.sh migration smoke-tested (moves stale state, idempotent on second run). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-homes the four web-client.ts features the fork lost when src/web-client.ts was deleted for the React client. Surgical port — upstream's inline-HTML implementations rebuilt as React components/hooks against the fork's client/ structure. - sonichi#650 markdown: marked + dompurify added; lib/chat-markdown.ts renders sanitized markdown in ConversationStream bubbles (finalized user/assistant turns only — system pills + interim streams stay plain text). Separate from lib/markdown.ts, which is notes-trust-only and must not render untrusted chat content. - sonichi#826 capabilities: static "What I can do" panel on the idle conversation screen (CapabilitiesPanel atom + const-values/capabilities.ts). - sonichi#651 subscriptions: subscription-scanner skill ported into skills/ (it was missing from the repo); web-server.ts gains GET /paidsubscriptions/data + localhost-gated POST /paidsubscriptions/scan; new /subscriptions React route with summary cards + sortable table. - sonichi#735 vision: web-server.ts /vision/* proxy to the voice-agent control server (127.0.0.1:7848); useVision hook drives getDisplayMedia capture + JPEG frame loop; Watch pill in the top bar + bottom-right VisionPreview. - sonichi#804 /presenter: excluded per the user — not ported. Verified: pnpm client build clean; tsc --noEmit clean; npm test 179/179. Live integration testing (vision streaming, scan task pickup) needs the full voice-agent stack — see .claude/5-19-2026-sync/09-manual-test-guide.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…11-12-app-webclient # Conflicts: # .gitignore # CLAUDE.md # README.md # package-lock.json # package.json # skills/cross-node-sync/scripts/regenerate-memory-index.py # skills/phone-conversation/scripts/conversation-server.ts # skills/screen-record/scripts/record.py # skills/subscription-scanner/SKILL.md # src/Sutando/main.swift # src/agent-api.py # src/archive-stale-results.py # src/browser-tools.ts # src/cartesia-tts.ts # src/dashboard.py # src/discord-bridge.py # src/event_log.py # src/github-webhook.py # src/health-check.py # src/init.sh # src/inline-tools.ts # src/screen-capture-server.py # src/session-handoff.sh # src/slack-bridge.py # src/startup.sh # src/task-bridge.ts # src/telegram-bridge.py # src/vision-tools.ts # src/vision_push.py # src/voice-agent.ts # src/voice-context.ts # src/watch-tasks-stream.sh # src/web-client.ts # tests/agent-state-endpoint.test.ts # tests/slack-bridge-tier-map.test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Third and final batch of the incremental upstream sync (
sonichi/sutando→AG2Platform/stando), stacked on #19 (CP-4–CP-7). Completes the checkpoint plan and lands the actualgit merge.Checkpoints:
conversation.log(node:sqlite, lazy-loaded so it can't crash callers on Node < 22.13).pgreppattern; the dashboard "brain offline" false alarm.meeting-tools.ts), discord-voice (+4 npm deps).sync-memory.sh.The merge: the branch then merges upstream
81c0f18, which advances the merge-base. 35 conflicts resolved — 27 code files to the checkpoint-reconciledHEAD(the checkpoints made resolution mechanical), 3 genuine union-merges (.gitignore,CLAUDE.md,package.json),web-client.tskept deleted,package-lock.jsonregenerated.140 files changed, +12,303 / −983.
Test plan
npx tsc --noEmitclean (root + client)npm test— 184 pass, 0 fail.app, smoke-test voice + conversation screennotes/skill-test-plan.md)Notes
merge/upstream-cp4-7. Merge Upstream sync phase 2 — CP-4 through CP-7 + Slack settings UI #19 first.conversation-server.tskept at--ours.0a8dd4c) can follow separately if fully-current is wanted.🤖 Generated with Claude Code