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

Upstream sync phase 3 — CP-8–CP-12, conversation-store, and the upstream merge - #38

Merged
jsun-m merged 169 commits into
mainfrom
merge/cp11-12-app-webclient
May 21, 2026
Merged

jsun-m merged 169 commits into
mainfrom
merge/cp11-12-app-webclient

Conversation

@jsun-m

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

Copy link
Copy Markdown

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 actual git merge.

Checkpoints:

  • feat(conversation-store): sqlite mirror of conversation.log + session rollups (#603 slice 1+3) sonichi/sutando#791 — conversation-store — SQLite mirror of conversation.log (node:sqlite, lazy-loaded so it can't crash callers on Node < 22.13).
  • agent-api fix — corrected the core-agent pgrep pattern; the dashboard "brain offline" false alarm.
  • CP-8 — new skills — deal-finder, whatsapp, context-drop, screen-companion, zoom (tools relocated out of meeting-tools.ts), discord-voice (+4 npm deps).
  • CP-9 — diagnostics + memory sync — self-diagnose cross-node gather, cross-node-sync MEMORY.md regenerator, sync-memory.sh.
  • CP-10 — misc tool fixes — type-text/press_key/scroll/open_url/cancel-task/screen-record/tts and more.
  • CP-11 — app / startup / init — watch-tasks retirement, startup perm-check + committer attribution, init.sh workspace migration, pause-loop submenu, launchd, agent-api/dashboard path hardening.
  • CP-12 — web-client features — ported into the React client.

The merge: the branch then merges upstream 81c0f18, which advances the merge-base. 35 conflicts resolved — 27 code files to the checkpoint-reconciled HEAD (the checkpoints made resolution mechanical), 3 genuine union-merges (.gitignore, CLAUDE.md, package.json), web-client.ts kept deleted, package-lock.json regenerated.

140 files changed, +12,303 / −983.

Test plan

  • npx tsc --noEmit clean (root + client)
  • npm test — 184 pass, 0 fail
  • Slack bridge Python tests pass
  • all 35 merge conflicts resolved; merge committed
  • rebuild the .app, smoke-test voice + conversation screen
  • live-test the CP-8 skills (notes/skill-test-plan.md)
  • verify dashboard no longer shows "brain offline"

Notes

🤖 Generated with Claude Code

sonichi and others added 30 commits May 13, 2026 09:04
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>
qingyun-wu and others added 27 commits May 19, 2026 12:08
…#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
Base automatically changed from merge/upstream-cp4-7 to main May 21, 2026 18:21
@jsun-m
jsun-m merged commit 4eb2e05 into main May 21, 2026
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.

8 participants