All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Event-driven inbound, a native top layer for every overlay, and a large verified bug sweep. Sessions can now be woken by chat instead of polling for it, every popup in the web UI moved onto the browser's native top layer behind a regression guard, and a deep-dive audit of the whole codebase closed a security hole plus dozens of real defects. Test wall-clock roughly halved.
- Channel server (
python -m claude_comms.channel). A one-way stdio MCP channel that subscribes to the broker over WebSocket and pushes only relevant traffic into a running Claude Code session asnotifications/claude/channel. An idle orchestrator costs zero tokens instead of 3k-10k per emptycomms_read, and is woken both while blocked on a subagent and while sitting idle at the prompt. A source-side wake filter decides what is worth waking for: explicit address (a whisper, a DM, a real@mention) wakes from any conversation, while ambient signal (@all, a human talking, system events, a reply in a thread you started) wakes only in joined conversations. Self-echo and whispers addressed to other people are dropped before any tier, the second being a confidentiality rule. Includes presence heartbeats so an idle session is not swept offline and excluded from@allexpansions. Interactive sessions only, and it reaches the main session loop only: subagents keep polling and are unaffected. broadcast_mentionon the wire.@allis expanded server-side into member keys, so after expansion a broadcast and an explicit mention were byte-identical. Both the tool path and the web composer now label broadcasts explicitly. Additive and backward compatible.claude-comms update. One command replaces the manual pull, web build, reinstall-if-needed, stop, start cycle. Refuses on a PyPI or wheel install.- Reactions: hover tooltip and who-reacted panel. Reaction pills show who reacted, with a detail panel per emoji.
@all/@everyonebroadcast mentions in the web composer and the MCP tool path, expanding to present members with the sender excluded.- Presence keepalive with TTL and resurrect semantics, so participants stop silently going offline mid-session.
- Overlay top-layer primitive: a
use:topLayeraction plus<Modal>and<Popover>wrappers built on the native Popover API and<dialog>, a--z-*design-token scale, and a two-tier regression guard (a vitest source scan plus a Playwright on-top hit test). - Stock agent definitions gained the Monitor tool with self-filter guidance, a facilitator agent, and a project
CLAUDE.md.
- Every overlay now renders in the browser's native top layer. Sixteen overlays plus the quick-join dialog migrated off ad-hoc z-index values, which previously spanned roughly twenty different numbers between 1 and 9999. Residual passive surfaces moved onto the token scale. The strict guard now allows exactly seven exceptions: four passive toasts and banners that must not steal focus, and three bits-ui components that already portal to
<body>. Any new overlay that skips the primitive, or any bare numeric z-index, fails the build. - Python test suite parallelized with pytest-xdist (
-n auto --dist=worksteal): about 31s down to about 12s cold, 6-9s warm. - Web test suite moved to the vitest
vmThreadspool, cutting per-file jsdom and import overhead: about 51s down to about 32s, with the jsdom environment cost down roughly 88 percent. src/is now fully type-annotated with basedpyright strictness enabled for source (945 diagnostics resolved by real annotations rather than suppression), and Python and web diagnostics both sit at zero.- E2E parallelized to four workers with slot-scoped daemon ports.
- Dependency pins:
mcp<2(mcp 2.0.0 movedmcp.server.fastmcpand broke every FastMCP import) and a pinned ruff in CI, so lint stops drifting with each upstream release.
- Indefinite publish hang.
comms_sendcould spin forever on a wedged MQTT publish, blocking the calling session. The persistent publish client now has a bounded timeout and reconnects once on failure. - Overlays rendering behind other panels. The status editor, mention dropdown, reaction bar, emoji picker, context menus and others were trapped in ancestor stacking contexts, so raising their z-index could never fix them.
- TUI dropped every
[system]message. The sender type literal excluded"system", so system messages failed validation and were swallowed. markAllReadnever advanced the server read cursor, so cleared unread counts and mention dots came back on reconnect.status: "sending"leaked onto the wire, leaving other clients showing a permanent sending spinner on messages that had long since arrived.@mentionstyling was lost inside bold and italic text.- Invisible text in the composer code-block editor, caused by an overlay transparency rule bleeding onto a sibling textarea.
update_namehad no uniqueness check, so renaming onto an existing name hijacked name-to-key resolution.- Composer
@mentionoverlay alignment, cursor drift and selection doubling; inline code containing backticks rendering garbled. - Artifact 409 conflict banner showing the wrong author and version; diffing against the oldest version requesting a nonexistent v0; version dropdowns not closing on outside click; the private-channel toggle being inert; the sidebar lock glyph keyed off the wrong field; emoji picker category tabs and search doing nothing.
- Flaky vitest teardown caused by a stray scroll-lock timer firing after jsdom was destroyed; a WebSocket-bridge last-will regression; a settings rename regression.
- Stored XSS in chat code blocks.
CodeBlockrendered raw, unescaped message content through{@html}whenever syntax highlighting had not yet resolved, which is every mount until the async highlighter returns. A code block containing markup from another participant executed in that window. The pre-highlight path now renders escaped text.
Single-origin hardening + admin moderation + concise MCP output + diagnostics + version SSOT. This release collapses the web deployment to a single trusted origin, lands real admin moderation (kick / channel delete / archive with server-hydrated roles), makes every MCP tool return a concise summarized payload, adds first-class connection diagnostics (claude-comms doctor + an in-UI readout), and introduces a single source-of-truth versioning system so the CLI, TUI, web UI, docs, and pip install can never drift apart again.
- Version single source of truth.
pyproject.toml [project] versionis now the one source of truth for the whole project.claude_comms.__version__derives from installed metadata (importlib.metadata); the CLI--versionreads it. The daemon advertises its own version onGET /api/capabilities(payload.version), and the web UI's sidebar badge prefers that live value over the build-timeweb/package.json— so a stale bundle can never display a wrong number.web/package.jsonis kept in lock step and a CI test (tests/test_version_consistency.py) fails the build on any drift. scripts/bump_version.py— one command (python scripts/bump_version.py X.Y.Z) validates semver, bumpspyproject.toml+web/package.jsontogether, prepends a CHANGELOG stub, commitsRelease vX.Y.Z, and creates the annotated tag. It refuses to re-bump an existing version or clobber an existing tag, and never pushes — pushing the tag is the deliberate human action that triggers the PyPI publish workflow.- README PyPI badge — a live
img.shields.io/pypi/v/claude-commsbadge replaces hand-maintained version text near the other badges. claude-comms doctor+ in-UI connection diagnostics — probe the web, REST, MCP, and broker legs from the CLI, mirrored by a browser-side readout in the Settings panel so a failed leg names what is down.- Admin moderation — kick a participant and delete / archive a channel, gated on the participant's real server-hydrated role (
owner/admin) rather than a creator-only heuristic.
- Single-origin serving. The daemon serves the page, REST, MCP, and broker bridge from one origin, so the Content-Security-Policy collapses to
'self'(no cross-originconnect-srcallowances needed in the default deployment). - Concise summarized output for ALL MCP tools — every tool returns a compact, human-and-agent-readable summary instead of verbose raw payloads, reducing token cost per call.
- basedpyright tests-scoped diagnostics — a
[[tool.basedpyright.executionEnvironments]]block (root = "tests") disables the unused-symbol / unreachable / unnecessary-type-ignore classes for thetests/tree only (pytest fixtures, signature-parity params, mock callback args are intentional throwaways);src/keeps full reporting.
- Phase 4 cross-origin config deprecation — legacy cross-origin web configuration now emits startup deprecation warnings; the single-origin path is the supported deployment.
- ruff format + ruff check clean; basedpyright introduces zero new targeted diagnostics on
src/claude_comms. - Full pytest suite green, including the new
tests/test_version_consistency.pydrift guard; vitest green;pnpm --dir web buildcompiles (baking the new0.5.0into the bundle as the fallback).
- No schema migrations. Redeploy the desktop app / restart the daemon so the running version is
0.5.0, then hard-refresh the browser (Ctrl+Shift+R) to pick up the new bundle. - Maintainers: pushing the
v0.5.0tag publishes to PyPI via the release workflow.
Second hotfix in 24 hours: Phil's manual Layer B re-pass against the just-shipped v0.4.3 caught 7 more user-visible regressions that the automated Playwright suite (which had passed 182/183 cold-start) missed. Demonstrates the core §I.19 principle — automated tests are built from assumptions; real eyes catch what assumptions don't think to look for. All 7 fixed with regression-pin tests + 50 new E2E tests added targeting the 7 anti-patterns that allowed each bug class to slip. No breaking changes, no schema migrations, no MCP tool changes.
- Right-click menus rendered BEHIND other elements (z-stacking bug). Root cause was NOT just z-index — a parent element's
backdrop-filtercreated a new stacking context that escaped viaposition: fixed. Fix: Svelte 5{@attach}portal helper (web/src/lib/portal.jsNEW) that mounts each context menu under<body>directly +z-index: 9999. Applied toMemberContextMenu,ChannelContextMenu, and submenu mounts. - Ctrl+N opens new Chrome window instead of ChannelModal (browser-default key consumption). Root cause: keyboard registry's editable-target rule returned BEFORE
event.preventDefault(), so even when handlers existed, the browser default fired. Fix:browserIntercept: trueopt-in flag onkeyboard.register()that callspreventDefault()UNCONDITIONALLY (even when target is editable — which is correct for Ctrl+N/L/W/Shift+W since the user's intent is the app shortcut, not browser-default). state_unsafe_mutationconsole error on new channel creation (getNotificationPolicypurity). Same anti-pattern class as v0.4.3'sgetChannelRolecascade fix: lazy-cache$statewrite from within a method called from$derivedevaluation context (Sidebar'sgetChannelNotificationPolicyvia SidebarChannelSection'snotificationPolicy$derived). Fix: makegetNotificationPolicya pure read; new#decodeNotificationPolicyForChannelprivate helper does pure localStorage decode; pre-warm calls added at all 4 channel-set-mutation sites (bootstrap + create + join +conversation_createdsystem event). Plus: project-wide accessor purity audit (P-11) confirmedgetNotificationPolicywas the only remaining hazard; the other 6get*accessors are already pure; the 2 lazy-write sites (createChannel,muteUserGlobally) are invoked from event handlers only (safe).- Right-click own username in MemberList — no menu appeared (zero-items short-circuit).
MemberContextMenu's visibility filter killed the mount entirely when all items were hidden for self. Fix: always mount the menu + show Mute-globally toggle for self + show "No actions available" empty-state row if everything else is filtered. - SearchPanel + SettingsPanel "unattached" from chat header (CSS regression). Both panels had a vestigial
top: 82pxfrom an earlier era when the chat header used a different position scheme.ArtifactPanel(the working reference) did NOT have this offset. Fix:top: 0on both, matching ArtifactPanel's pattern verbatim. - ThreadPanel first-open clobbers chat history + doesn't show replies (first-mount race). Synchronous batched state-write during ThreadPanel mount:
markThreadSeenwriting$statemid-render race-conditioned with the chat view's$derivedthat depended on the same fields. Fix: defermarkThreadSeenviatick().then(() => ...)to next microtask. First-open now mounts cleanly without clobbering adjacent UI.
web/e2e/fixtures/topLayer.ts(NEW, +130 LOC). ExportsexpectLocatorOnTop,expectOnTop,topElementTestIdAtusingdocument.elementFromPoint+ ancestor-chain walk to verify a locator is the topmost element at its center coordinates. Direct mitigation of W-8 (.toBeVisible()doesn't check stacking).- Targeted anti-pattern mitigation tests in the existing 10 e2e scenarios:
- W-8 (top-layer hit-test): 15 tests across 8 scenarios using
expectLocatorOnTop - W-9 (browser-intercepted shortcuts): 6 tests in scenario 02 covering Ctrl+L/N/W/Shift+W runtime + source-pin on
browserIntercept: true - W-10 + W-11 (creation flow + project-wide accessor audit): 7 tests across scenarios 02 + 08 exercising new-channel-creation cache-pre-warm
- W-12 (two-stage menu mount/items): 5 tests in scenario 04 covering MemberContextMenu self-case with explicit mount + items two-stage assertion
- W-13 (regenerated baselines): 6 tests in scenario 07 covering panel alignment regression + 4 regenerated baseline PNGs visually verified
- W-14 (open-preserves-pre-state): 6 tests in scenario 10 covering ThreadPanel first-open + chat-view-still-visible
- W-8 (top-layer hit-test): 15 tests across 8 scenarios using
- 3 pre-existing tests fixed for v0.4.4 contract drift: MemberContextMenu self-row
canMutechanged from!isSelftotrue(Bug 4 fix); SearchPanel + SettingsPanel close paths use the panel's own close button (W-15 mitigation — trigger button now overlaid).
- P-11 (ENFORCE): project-wide accessor purity audit. Extends P-9 (sibling-function bug shape scan within file) to PROJECT-WIDE within the same file. Every
if (!this.cache[k]) { this.cache[k] = compute(); } return this.cache[k];pattern is astate_unsafe_mutationhazard if called from any$derived. Codified in.worklogs/v043-iteration-log.md. - P-12 (ENFORCE):
document.elementFromPointhit-testing viaexpectLocatorOnTop. Mandatory for any menu/popover/overlay e2e test. - W-8 (AVOID):
.toBeVisible()≠ "on top of stacking context." - W-9 (AVOID): Playwright
keyboard.pressdoesn't simulate browser-default key consumption. - W-10 (AVOID): Testing only on seeded fixtures misses dynamic creation/join flows.
- W-11 (AVOID): Lazy-cache-write anti-pattern not project-wide audited.
- W-12 (AVOID): Visibility-matrix tests that don't distinguish "menu open with reduced items" from "menu doesn't open at all."
- W-13 (AVOID): First-run screenshot baseline codifies whatever state existed at capture; needs adversarial visual review of every NEW baseline before commit.
- W-14 (AVOID): "Open X" tests don't assert what was visible BEFORE the open is STILL visible AFTER.
- W-15 (AVOID): Panel-toggle tests that re-click trigger button to close are fragile after CSS positioning changes (trigger gets overlaid by panel); use the panel's own close button.
- Pytest 1347 unchanged (no Python regressions).
- vitest 1107 → 1139 (+32 from 5 new bugfix specs).
- Playwright e2e: 232 passing + 1 intentional skip out of 233 cold-start (the v0.4.3
transfer-picker-openflake remains skipped; addressed in a future cycle per W-7). - ruff clean, build green, svelte-autofixer clean on every component touched.
- Layer A install smoke + your Layer B manual pass against the v0.4.4 PyPI wheel.
- No schema migrations; no MCP tool changes; no breaking changes.
- If you hit any of the 7 v0.4.3 regressions, this release fixes them all.
- Hard-refresh the browser (
Ctrl+Shift+R) after upgrade to load the new bundle.
Hotfix + comprehensive Playwright E2E suite + ThreadPanel drag-resize + new orchestration framework rule. Phil's v0.4.2 Layer B real-browser smoke surfaced 5 user-visible regressions: a state_unsafe_mutation cascade originating from a lazy-cache write in getChannelRole (which aborted App.svelte's render tree mid-flight + cascaded to 4 visible symptoms), plus 2 production bugs discovered DURING the v0.4.3 E2E build (ChannelModal Create button swallowed under bits-ui focus-trap; ChannelAdminPanel topic-Enter double-fire wiping the topic), plus 1 unread-cursor persistence bug found by Phase 2 Agent C's spec-vs-code freshness audit (#restoreUnreadMarkers ran before channelsById was populated → dead code → unread cursor didn't survive tab close+reopen). All 5 fixed with regression-pin tests. ThreadPanel gained drag-resize (Phil's mid-Layer-B feature request, mirroring ArtifactPanel pattern). Comprehensive Playwright E2E suite covers all 12 of Phil's Layer B exercise scenarios (183 tests, screenshot baselines).
Also includes a NEW orchestration framework rule (§I.19 refine-as-you-go) that emerged directly from this hotfix's discipline: orchestrator must be hands-on with every subagent output, maintain a permanent per-phase iteration log, mutation-test test-writing agents' work, and refine each subsequent brief based on prior findings. Iteration log compounds patterns across the dispatch chain instead of rediscovering them each agent.
state_unsafe_mutationcascade ingetChannelRole(mqtt-store.svelte.js). The accessor did a lazy-cache write (this.channelRoles[channelId] = role) that fired from a$derivedevaluation context. Svelte 5 forbids$statemutations inside$derived; the throw aborted App.svelte's render tree mid-flight. Visible symptoms cascaded: (a) console error storm, (b) ChannelModal create + cancel buttons appeared broken (App didn't reach the modal mount cleanly), (c) ChatHeader 6 buttons appeared invisible (markup never reached DOM), (d) right-click on own username in MemberList threw + no menu appeared. Fix: makegetChannelRolea pure read; move cache population to bootstrap (mirror of Wave G's#prewarmNotificationPoliciespattern). One root cause, four symptoms resolved.- ChannelModal Create button intermittently swallowed under bits-ui Dialog focus-trap (
ChannelModal.svelte). The focus-trap intercepts syntheticclickevents whose target element flipsdisabledin the same microtask. Every Playwright synthetic-click path was affected, and Phil's real-browser sessions saw it intermittently. Fix: wireonpointerdownalongsideonclick; pointerdown fires before the focus-trap's interception.submitting = $state(false)latch dedupes the double-path;e.button !== 0rejects right-click. - ChannelAdminPanel topic-input fires
commitEditTopictwice on Enter, wiping the topic (ChannelAdminPanel.svelte).onkeydown=EnterseteditingTopic=false(unmounting the input) and the unmount'sonblurre-enteredcommitEditTopicwith already-resettopicDraft=''→ calledstore.setTopic(id, ''). Fix:if (!editingTopic) return;at the top ofcommitEditTopic. Same guard preventively applied tocommitRename(sibling function with identical wire shape) per the §I.19 sibling-function bug shape scan. - Unread cursor doesn't survive tab close + reopen (
mqtt-store.svelte.js).#restoreUnreadMarkersUSED to be called fromconnect()BEFORE#bootstrapChannelspopulatedchannelsById. The rehydration loop walked an empty map; localStorage cursor was effectively dead on cold load. Fix: move the call into the tail of#bootstrapChannels, afterchannelsByIdis populated. Surfaced by Phase 2 E2E Agent C's source-vs-code freshness audit. - App.svelte toast handler suppressed in-app toasts on muted channels regardless of mention (v0.4.2 follow-through). Now policy-aware decision tree mirrors the browser-Notification gate (this fix actually shipped in v0.4.2 commit
942761d; re-listed here for emphasis since Layer B confirmed the v0.4.2 behavior is correct).
- ThreadPanel drag-resize handle (
ThreadPanel.svelte). Mirrors ArtifactPanel's existing pattern: PointerEvents API + ARIA window-splitter (role="separator",tabindex,aria-valuenow/min/max) +setPointerCapture+ viewport-aware clamp + safeStorage wrapper + window-resize$effect. Three panel-tuned constants:MIN_PANEL_WIDTH=280,MAX_PANEL_WIDTH=720,DEFAULT_PANEL_WIDTH=360. Storage keyclaude-comms:thread-panel-widthmirrors ArtifactPanel's naming convention. Keyboard a11y: ArrowLeft/Right nudges (KEY_STEP=16), Home/End jump to clamp extremes. Phil's mid-Layer-B feature request.
- Playwright E2E suite from scratch at
web/e2e/. Comprehensive coverage of all 12 of Phil's Layer B exercise items. 10 scenario specs:01-join-and-history.spec.ts— Phil's #1 (#generalhistory visible without explicit join; confirmed intentional lobby feature)02-create-channel.spec.ts— Phil's #4 (ChannelModal create + cancel + sanitize-name)03-admin-actions.spec.ts— Phil's #3 (cascade prevent) + admin actions (rename + visibility + mode + archive + delete + transferOwnership)04-member-context-menu.spec.ts— Phil's #5 (right-click own username no error) + kick + mute-globally + DM05-invite-participant.spec.ts— Phil's #6 (InviteParticipantDialog + 4xx error paths viapage.route()intercept)06-status-editor.spec.ts— Phil's #7 (StatusEditor popover + 4 expiry presets + clear)07-chat-header-buttons.spec.ts— Phil's #8 (6 buttons visible + each toggles its panel)08-notification-policy.spec.ts— Phil's #9 + #10 (kebab quickview cycle + highlight-words + browser-Notification gate)09-unread-divider.spec.ts— Phil's #11 (UnreadDivider + IntersectionObserver 1s-dwell unread-clear)10-thread-panel.spec.ts— Phil's #12 (close button + scrollbar overflow + drag-resize)
- 183 Playwright tests (182 passing + 1 intentional skip due to sub-pixel screenshot flake documented as W-7 anti-pattern in iteration log; behavior fully covered by surrounding STATE assertions).
- ~25 screenshot baselines committed to
web/e2e/__screenshots__/. Visual regression detection viaexpect(page).toHaveScreenshot()with masked timestamps. - Per-test daemon fixture with isolated
HOME=/tmp/cc-e2e-<random>+ port slots (MCP 9930+N10, web 9931+N10) +node:sqlitedirectregistry.dbseeding + PlaywrightconsoleErrorsspy assertion (nostate_unsafe_mutationacross the full suite). web/e2e/README.mdwith run/regen/troubleshoot protocol.
- §I.19 refine-as-you-go (Phil-locked 2026-05-20, codified at
.worklogs/architecture-and-orchestration-plan.md§I.19 + memoryfeedback_refine_as_you_go.md). For every multi-agent dispatch sequence, orchestrator must: (a) be hands-on with every subagent output (read worklog + skim diff + run gates), (b) maintain a permanent.worklogs/<phase>-iteration-log.md, (c) refine each subsequent brief based on prior agent findings, (d) mutation-test test-writing agents' work to confirm tests would actually catch the bug they were written to prevent. Composes with §I.16/§I.16.5/§I.17/§I.18 standing rules. - Pattern catalog in
.worklogs/v043-iteration-log.md: 11 ENFORCE patterns (P-1..P-9 + P-2a + P-3a covering source-level regex pins, cross-component invariant pins, dual-coverage, localStorage round-trip, console.error spy, cold-start verification, daemon filesystem reads, pre-click state assertions, sibling-function bug shape scan, triple-side prop-drill pins,page.route()capture-then-fulfill) and 7 AVOID patterns (W-1..W-7 covering unrestored window mutations, DOM-presence vs computed-visibility, tautological tests,git add .family, self-report without cold-start, workaround tests, cumulative test-file state without between-test reset). Available for future projects beyond claude-comms.
web/playwright.config.js:testDirchanged from./e2eto./e2e/scenarios; removed VitewebServer(E2E daemons serve their own static bundle).--workers=1MANDATORY per [VERIFY-PHASE2B-1] (UI hardcodes MQTT WS port9001→ all test daemons share the broker → can't run concurrently). Bake into config in v0.4.4.
--workers=1mandate for E2E. Resolution: lift UI's hardcoded MQTT WS port + make it configurable so each test daemon binds distinct broker ports.transfer-picker-openscreenshot intentionally skipped due to sub-pixel font rendering flake under cumulative test-file state (W-7). Behavior fully covered by surrounding state assertions.apiPostthrowsError('HTTP <status>'); bleeds through to user-facing toasts via App.svelte'smsg = msg || 'fallback'truthy-keep. UX cleanup.- ArtifactPanel overlay intercepts pointer events on chat-header-artifacts-btn at 1280px+ viewports. Click-again-to-toggle fragile; close-via-own-X is canonical.
data-testid="artifact-panel-close"duplicated inArtifactDetailHeader.svelteandArtifactPanel.svelte. Rename one.- IntersectionObserver viewed-set state persists across channel switches. Production fix: reset
#viewedMessageIdsByChannel[id]on channel-switch-out.
- Pytest 1347 unchanged (no Python regressions; v0.4.3 is frontend + test-infra only).
- vitest 1103 → 1107 (+4 from the unread-marker bugfix's regression-pin tests; +16 from bug-fix-mini's modal+topic Enter regression-pin tests = 1123 cumulative; the snapshot above is post-final-integration).
- Playwright e2e: 182 passing + 1 intentional skip out of 183 cold-start.
- ruff clean, build green, svelte-autofixer clean on every component touched.
- Manual two-layer smoke (per §I.10): Layer A install smoke runs after PyPI propagation; Layer B real-browser pass by Phil.
- No schema migrations; no data migration required.
- No MCP tool count change (stays at 30; v0.4.3 is bug fixes + UX feature + test infra).
- ThreadPanel will appear with a drag-resize affordance on the leading edge — drag to resize, or use ArrowLeft/Right when the handle is focused, or Home/End to clamp.
- If you were on v0.4.2 and hit any of the 5 fixed regressions, just upgrade + hard-refresh the browser (
Ctrl+Shift+R) to load the new bundle.
Admin actions + notifications + status presence + IntersectionObserver unread. The originally-planned v0.4.1 admin-actions phase that the hotfix bumped here, expanded by Phil's Q6/Q7/Q8 + Archive UX lock-ins at decision gate G1. Largest functional release since v0.4.0: 28 commits, 5 new MCP tools, 2 new schema versions on participants, a new REST endpoint, end-to-end persistence for every admin button surfaced in v0.4.0's UI, and a Slack/Discord-style IntersectionObserver-based unread model that replaces the v0.4.0 "switch-clears-unread" semantics. Also includes a real bug fix for a live-MQTT mention-derivation gap that affected mention-dot rendering in v0.4.0/v0.4.1.
- Q6 admin role model: explicit
rolefield per channel (owner|admin|member); creators of pre-existing channels grandfathered asownervia idempotent backfill. Replaces v0.4.0's implicit "is this the createdBy" heuristic. - Q7 notification policy granularity: ship
All/Mentions/OffPLUS per-channelhighlight-wordsarray. Substring match on incoming message body raisesunreadHasMention=trueeven without formal @-mention. - Q8 mute UI placement: keep inline mute icon + add kebab quickview row that shows current policy + 1-click cycles to next.
- Archive UX: extend
TypeNameConfirmDialogwithseverity: 'danger' | 'warning'prop; warning short-circuits the typed-name gate. Archive uses warning (reversible); Delete keeps danger (typed name required).
ChannelAdminPanel.sveltemounted inChannelDirectoryModal's Admin tab. Role-gated visibility:ownersees Rename / Transfer Ownership / Set Visibility / Set Mode / Archive / Delete;adminsees Rename / Visibility / Mode / Archive (no Transfer, no Delete);membersees an empty-state. All destructive actions route through the sharedconfirmDestructivehelper with appropriate severity.MemberContextMenu.svelteopens on right-click of any member row inMemberList. Actions: Kick (gated owner/admin viastore.getChannelRole); Mute globally / Unmute globally (localStorage per the v0.4.0 Q4 pattern); Start DM (excluded for self). Destructive paths route throughconfirmDestructive(severity='danger').InviteParticipantDialog.svelteopened via the new "Invite participant..." item inChannelContextMenu. Search-as-you-type participant picker, optional note (200-char limit), wires to existingcomms_inviteMCP tool via the new POST/api/inviteREST endpoint.StatusEditor.svelteopened from a new inline status row inSidebarunder the identity row. Emoji strip + free-text emoji input + 60-char text input with live counter + 4 expiry presets (Never / 1h / 4h / Until tomorrow). Status persists via newcomms_profile_status_set/_clearMCP tools; broadcasts on the existingclaude-comms/presence/{key}/{connKey}retained topic augmented withprofile_status_emoji/text/expires_atkeys. Auto-expire coroutine sweeps periodically.ChatHeader.sveltenew component mounted at the top ofChatView, replacing the legacy inline header inApp.svelte. Inline role-gated topic edit (click topic to enter input mode; Enter saves viastore.setTopic; Esc cancels). Six button affordances restored from the legacy header: Search, Pinned, Artifacts, Settings, Theme toggle, Mobile menu.SystemMessageGroup.sveltecollapses runs of 3+ consecutive system messages into a single expandable summary ("Alice joined, Bob left, Carol archived #general"). 1-2 consecutive system messages render inline as before.UnreadDivider.svelteinserts a "{N} new" separator between the last-read message and the first-unread message inChatView. Position computed via the channel'sunreadFromcursor; self-hides when count is 0 or cursor not in viewport.ThreadPanel.svelterefactored to useMessageInputvia a thread-scoped store proxy. Composer parity with the main channel: slash commands work in thread, emoji picker available, draft state preserved. Plus visible close button + proper scrollbar overflow on the reply list (UX requested mid-session).NotificationPolicyMenu.svelteopened from the kebab "Configure notifications..." item. All/Mentions/Off radio + comma-separated highlight-words text input. Q8 kebab quickview row above shows current policy + 1-click cycle. SidebarChannelRow renders bell variants (Bell for All, BellDot for Mentions, BellOff for Off) once bootstrap pre-warms the policy cache.
tool_comms_kick(registry, *, key, conversation, target_key, ...)kicks a member from a channel. Caller must beowneroradminperRegistryStore.get_channel_role. Removes target's membership row; publishes a[system]message on the channel's MQTT topic.tool_comms_dm_open(registry, *, key, target_key, ...)synthesizes a deterministic DM slugdm-{min(key,target_key)}-{max(key,target_key)}. Idempotent: returns{status: "existed"}if the slug already exists, otherwise creates the channel withvisibility="private"+mode="invite", auto-joins both parties, and sets symmetricownerrole for both.tool_comms_get_channel_role(registry, *, key, conversation, target_participant_key=None)thin MCP wrapper exposing the per-channel role API to the frontend. Returns{role, participant_key, conversation}. Caller must be a member of the conversation.tool_comms_profile_status_set(emoji, text, expires_at=None)+tool_comms_profile_status_clear()persist user-visible status on the participants table (profile_status_emoji/_text/_expires_at); broadcast augmented presence payload; auto-expire coroutine clears expired statuses. Renamed from the originalcomms_status_setafter a pre-dispatch audit caught a collision with v0.4.0's existing ephemeral activity API atmcp_tools.py:1198.- POST
/api/inviteREST endpoint bridges existingcomms_inviteMCP tool. Body{conversation_id, invitee_key, note?}; response{invited, invitee_key, conversation_id}. Returns 403 (caller not member), 404 (conv not found), 400 (malformed body / unknown invitee), 409 (invitee already member). CORS-coverage confirmed via the v0.4.1CORSMiddlewarewrap. - Extended
tool_comms_conversation_updatenow accepts optionaldisplay_name/visibility/mode/created_bykwargs alongside the existingtopic. Multi-field updates apply atomically + send one combined system message.name(storage slug) is REJECTED with_error— rename is viadisplay_name; slug stays immutable for MQTT topic stability. Validatesvisibility in {"public","private"}+mode in {"open","invite"}. ConversationMetaschema extensions: nullabledisplay_name+visibility(default"public") +mode(default"open") fields. Backwards-compatible: existing meta JSON files load cleanly via Pydantic defaults.participantstable schema bumps: v1 -> v2 addsconversation_rolesassociation table (Q6 lock-in, with idempotent backfill that grandfathers creators asowner); v2 -> v3 adds nullableprofile_status_emoji/_text/_expires_atcolumns (idempotent ALTER TABLE viaPRAGMA table_infointrospection).comms_checkon connect + visibility-regain in the store. Batched per-channel unread fetch hydrateschannels[id].unread/unreadHasMention/lastActivity. 30-second throttle on rapidvisibilitychangeevents.
- Unread is now viewport-confirmed, not channel-switch-cleared (UX G-18). Switching to a channel no longer auto-zeros its unread count. Each message bubble is observed by a shared
IntersectionObserver; after 1 second of dwell in the viewport, the message is marked seen. When all unread messages have been seen, the channel's unread zeros out. "Mark all as read" still forces immediate zero. Matches Slack/Discord behavior most users expect. - App-level browser Notification gate honors per-channel policy. Browser
NotificationAPI no longer over-notifies on muted channels. Decision tree:policy='Off'never notifies;policy='Mentions'notifies only whenunreadHasMentionis true (formal mentions OR highlight-word matches);policy='All'notifies always except when channel is muted AND message is not a mention (mentions bypass mute for cross-channel awareness). unreadHasMentionderivation now fires on live MQTT messages, not just bootstrap. v0.4.0 set this flag duringcheckChannels(bootstrap) but missed the live#handleChatMessagepath; muted-channel mention dots were silent until the next bootstrap. Surgical fix in the live-MQTT handler.- Admin-action store accessors land with disconnected-state queuing.
store.renameChannel/setVisibility/setMode/transferOwnershipmirror v0.3.3'spendingSendspattern from Polish P6'sforwardMessage. On reconnect, queued operations flush in order.
- App.svelte toast handler suppressed in-app toasts on muted channels regardless of mention. Pre-fix: any message on a muted channel suppressed the toast, even @-mentions of the current user. Post-fix: policy-aware decision tree mirrors the browser-Notification gate above.
- Mention dot on muted channels via live MQTT (Design Spec §8.2 invariant). Same root cause as the toast handler — derivation only fired in bootstrap. Fixed in
#handleChatMessage.
Mid-flight process improvements codified into .worklogs/architecture-and-orchestration-plan.md §I.18 and the orchestrator memory feedback_pre_dispatch_edge_map.md:
- §I.18 cross-codefile edge map (Phil-requested 2026-05-13). For any wave of N>=2 parallel coding subagents, orchestrator produces a pre-dispatch artifact pinning the EXACT name + signature of every cross-file symbol (REST shapes, JSON-RPC method names, MQTT topics, DB columns, event-name strings, localStorage keys, CSS classes, function signatures). The pinned block is copy-pasted verbatim into every brief. Agents instructed to STOP and surface to orchestrator rather than improvise pinned names. Generalization of §I.17 stub-agent pattern (which only covered Svelte import-time edges) to ALL runtime-coupled edges.
- §I.18 step 1.5: spec-vs-code freshness audit. Before drafting any brief, run
git grep <each pinned name>AND read the actual function/class/schema definition for every spec claim about existing code. Caught and resolved 3 stale-spec gotchas in v0.4.2: (a)comms_status_setname collision with v0.4.0's ephemeral activity API, renamed tocomms_profile_status_*; (b)comms_conversation_updateonly acceptedtopicnot the 5 fields the spec assumed, leading to a new Step 3.6b backend extension; (c)ConversationMetalackedvisibility/modefields, also resolved in 3.6b. - §I.18 step 2b: old-value-set grep. When extending a model field's value-set (e.g. visibility
'listed'/'unlisted'placeholder to pinned'public'/'private'), also grep for existing hardcoded values from the OLD value-set across all consumer trees. Caught by a follow-up agent when the original audit missed the_serialize_conversation_fullplaceholder values.
- Pytest 1268 -> 1347 (+79, zero regressions).
- vitest 745 -> 1061 (+316, +42% growth).
- ruff clean across
src/+tests/. - pnpm build green; svelte-autofixer clean on every component touched.
- Manual two-layer smoke (per §I.10): Layer A install + Layer B real-browser exercise of admin actions / member kebab / StatusEditor / ChatHeader buttons / Notification policy quickview.
- Schema migrations are idempotent: legacy v0.4.0/v0.4.1 SQLite registries auto-upgrade on first daemon start (v1 -> v2 -> v3). No data loss; missing columns default to nulls / safe values.
- The renamed
comms_status_*tocomms_profile_status_*distinction: the v0.4.0 ephemeral activity API ("looking at #channel" TTL ~30s) is UNCHANGED — same function names, same MQTT topic, same semantics. v0.4.2's NEW persistent profile-status tools live alongside undercomms_profile_status_*names. Both APIs coexist; clients of the legacy activity API need no changes. - Channel "rename" via the new Admin panel sets a
display_namefield; the underlying slug (used in MQTT topics + on-disk paths) stays immutable. Existing clients see the new display name in UI; topic subscriptions unchanged. - Browser notifications now respect per-channel policy; if you previously relied on Notification spam, configure per-channel
Offpolicy via the kebab.
Hotfix. v0.4.0 shipped with two showstopper bugs that surfaced as soon as Phil exercised channel operations from a fresh install: the web UI banner blinked between "Establishing secure connection" and brief connected flashes at ~10 Hz, generating 1500+ requests per minute, with no console output to suggest a fault. Two independent root causes, both in v0.4.1.
- Infinite-loop init: App.svelte's
connect()$effectre-fires on every store mutation (web/src/App.svelte). The$effectblock that calledstore.connect()on mount synchronously readstore.nameUnsetandstore.userProfile.*(insideconnect(), before the first await), and those reads were TRACKED by the surrounding effect. As soon asconnect()later mutated those same fields during the identity fetch + name resolution, the effect's tracked deps changed and the effect re-ran, callingconnect()again. The cleanup function tore down the half-formed WebSocket each cycle, so MQTT.js produced no console output (each connection died before itson('connect')handler could settle the UI state). v0.3.3 had the same$effectpattern but v0.4.0 expanded the$statesurface inconnect()'s sync portion (Steps 2.5 + 2.6 addedserverUnreachable,channelsByIdmap writes, moreuserProfilemutations) which is what tripped the loop. Fix: replace$effect(() => { store.connect(); ... })with Svelte'sonMount(() => { store.connect(); ... }).onMountdoesn't track anything; the init call runs once. - CORS gap on
/mcpendpoint (src/claude_comms/cli.py). The Starlette app returned byFastMCP.streamable_http_app()did not include CORS middleware. The REST/api/*routes had CORS handling via_cors_headers()from v0.3.0 onward;/mcpwas a v0.3.3 addition (Step 1.9 SettingsPanel display-name) that bypassed that path. v0.4.0 then expanded browser-side MCP usage through the newapi.mcpCallhelper (joinChannel,leaveChannel,setTopic,archiveChannel,deleteChannel,comms_conversation_update), making the gap user-facing. Fix: wrapstarlette_appwith Starlette'sCORSMiddlewareafter all.routes.insert(...)calls, reusing the samecors_originslist the REST routes already consume (loopback ports 9921 + Vite dev ports + optionalweb.api_base). Adds standard MCP headers (Mcp-Session-Id,Mcp-Protocol-Version) to the allowed-headers list.
- Pytest 1268 (unchanged from v0.4.0; no Python regressions).
- vitest 745 (unchanged from v0.4.0; no JS test changes — fix is structural).
- Manual smoke required (browser-side timing bugs don't reproduce in jsdom). Verification post-install:
claude-comms stop && claude-comms start --background --webafter the upgrade- Open the web UI; the "Establishing secure connection" banner should appear briefly (under a second) and then transition to "Connected — N participants online" without blinking
- DevTools Network tab should show ~5 initial requests followed by quiet idle (no per-second
conversations/identityrepetition) - Try changing display name in Settings; should succeed (was blocked by CORS in v0.4.0)
- Try right-click a channel → Star, Mute, Leave — should all succeed
- ruff clean, build green.
If you were on v0.4.0 and saw the connection banner blinking + the web UI unresponsive: upgrade to 0.4.1 and restart the daemon (claude-comms stop && claude-comms start --background --web). Hard-refresh the browser (Ctrl+Shift+R) to load the new bundle. No data migration; no config changes.
The originally-planned "admin actions" phase moves from v0.4.1 to v0.4.2 in the architecture doc — the v0.4.1 slot now holds this hotfix only.
Three-section sidebar + full channel management + keyboard shortcuts + slash commands. Largest release since v0.3.0. 18 implementation steps shipped (out of 21 planned; 3 deferred per Phil's Q-decisions before phase kickoff: server-side mute, channel preview, omnibar). 22 commits since v0.3.3 ship; +311 vitest tests (+72% test growth); zero pytest regressions; zero shipped behavior regressions. Also includes 4 new orchestration-framework process improvements codified across the phase: pre-wave contract stub agents (§I.17), integration-agent pattern (§I.16.5), wave isolation (§I.16), and explicit-git add rules; all validated by real waves in this release. See .worklogs/architecture-and-orchestration-plan.md for the framework + every step's worklog.
- Three-section sidebar (Phil's headline feature). The single channel list is gone. Replaced with three always-visible sections (Starred / Active / Available), each with its own header, chevron, count badge, and inline empty-state copy. Sections never disappear even when empty (Phil's M-FIX invariant extended from MemberList to channels). Per-section collapse state persists in
localStorage. (SidebarChannelSection.svelte, Step 2.9.) - Atomic
SidebarChannelRow.svelterendering each channel with: mode glyph (Hash / Lock), name (bold when unread), member-count chip (hover or always-visible per section), topic preview (Available only), unread badge (mention-dot variant in--ember-400whenunreadHasMentionper Spec §8.2), mute icon, star icon (filled in Starred section, hover-visible elsewhere). Right-click opens context menu. (Step 2.8.) - Channel right-click context menu (
ChannelContextMenu.svelte, Step 2.10, Phil's CTX-MENU-NEW). 9 actions with visibility perisMemberxisCreator: Star/Unstar, Mute (submenu: All / Mentions / Off), Mark all read, Copy link, Leave (members non-creators), Close (creators, archives per Q1 lock), Delete (creators), Channel info. Keyboard navigation, Escape closes, outside-click closes. LeaveChannelDialog.svelte(Step 2.11). Confirmation modal triggered only on heavy-investment leaves (over 50 messages sent, or pinned messages authored, or starred channel with auto-unstar warning). Light-investment leaves fall through silently. Focus trap, default-focus on Cancel button (destructive action; user actively picks Leave).ChannelDirectoryModal.svelte(Step 2.13, Phil's Ctrl+L). Full-screen modal with Browse and Admin tabs. Browse tab mounts the refactoredConversationBrowserwith locked alphabetical sort (per Phil's SORT-LOCK), 4 sub-sections (Public listed / Public unlisted-accessible / Archived / My private channels), and live filter. Admin tab visible only when user owns 1+ channel; per-channel actions for v0.4.0 MVP: Edit topic, Archive, Delete. Modal a11y: focus trap, Escape closes, return focus to invoker.ConversationBrowser.sveltedual mode (Step 2.14). When used standalone (back-compat), renders with its own modal chrome + internal filter. When used embedded inside ChannelDirectoryModal, strips outer chrome + acceptsfilterValue/sortKeyfrom parent.- 15-second undo machinery on
archiveChannel+leaveChannelstore methods. Each returns{ done, cancel }. The sidebar's leave/close handlers wire the envelope so users have 15 seconds to undo before the MCP call commits. Per Design Spec §4.1.
comms_conversation_deleteMCP tool (Step 2.2). Two-phase:confirm=Falsereturns{"error":"confirm_required", "message_count":N, "member_count":M}for the type-name modal;confirm=Truepublishes a final retained{"type":"deleted",...}system message + retained-clears each member's presence + soft-deletes the registry row (preserves history on disk; future purge job hard-deletes).comms_conversation_archive+comms_conversation_unarchiveMCP tools (Step 2.3, Phil's Q1=Archive+kick locked). Archive preserves history, setsarchived=True+archived_at/archived_by, rejects new sends at the MCP layer (viaconv_data_dirguard intool_comms_send), retained-clears member presence. Unarchive flips state back; does NOT auto-re-join members per Spec §4.4.ConversationMetaschema additions:deleted_at,deleted_by,archived_at,archived_byfields +mark_deleted,is_deleted,mark_archived,mark_unarchivedtransition methods./api/conversationsextended to full ChannelRow payload (Step 2.1, Phil's S-FIX backend). Returns the daemon's full known conversation set (not just caller's memberships), with new fields per row:member,memberCount,lastActivity,mode,visibility,createdAt,createdBy,myUnread,myStarred,myMuted,archived,archived_at,archived_by.tool_comms_conversations(all=True)surfaces archive fields for the directory's Archived sub-tab routing.- Store bootstraps channels from
/api/conversations(Step 2.5, Phil's S-FIX web). Hardcoded seed channels removed. Edge cases handled: 0-rows takes channels to empty, 404/500 setsserverUnreachablereactive flag and a "Server unreachable" banner in App.svelte. channelsByIdmap + 4$derivedprojections in the store (Step 2.6):starredChannels,activeChannels,availableChannels,archivedChannels; all alpha-sorted per SORT-LOCK. 8 lifecycle methods:joinChannel,leaveChannel,archiveChannel,deleteChannel,closeChannel(delegates to archive per Q1),setTopic,setMute(localStorage per Q4 lock),setStar(localStorage).- Full system-event taxonomy handler (Step 2.7) on
claude-comms/system/conversations:created,topic_changed,renamed,deleted,archived,unarchived,member_joined,member_left. Defensive default branch logs structured + skips on unknown types. User-currently-viewing-this-channel switch logic for deletes + archives. NewlatestChannelLifecycleToastreactive field for the sidebar to consume.
- Keyboard shortcuts registry (Step 2.17):
Ctrl+Lopens directory,Ctrl+W/Ctrl+Shift+Wleaves current channel,Ctrl+Ncreates channel,Ctrl+Jquick-join prompt,Alt+1-Alt+9jumps to Nth channel in Active section (alpha-sorted per SORT-LOCK),?opens keyboard-shortcuts help overlay. Focus-context rule: bindings only fire when no input/textarea is focused (Escape always fires). - 12 slash commands (Step 2.18):
/join,/leave,/list,/topic,/close,/star,/mute [all|mentions|off],/me <action>,/clear,/help [command],/who,/nick <new name>. Inline error/ok toast feedback. Unknown command shows usage hint.
- 4-phase reactive transitions (Step 2.15, Design Spec §10). When a channel moves between sections (join, leave, star, close), the row fades out of the old section, gap collapses, gap grows in the new section, row fades in. ~900ms total.
prefers-reduced-motion: reducesnaps instantly. Star toggle uses a shorter crossfade (~300ms). - Centralized empty-state copy module (
web/src/lib/copy/emptyStates.js, Step 2.16, Design Spec §11). 15 keys + 1 function (filterEmpty(filter)). Imported by MemberList, ChatView, ChannelDirectoryModal. Friendly, brief, actionable. Zero em dashes enforced by an automated test that scans the module.
Sidebar.svelterewritten as thin shell (Step 2.12). 716 to 295 lines (-58.8%). Composes the new sidebar components (Section + Row + ContextMenu + LeaveChannelDialog). Logic that used to live in this file is now in the focused subcomponents.- MessageInput intercepts
/-prefixed input for slash-command routing (Step 2.18). Non-slash sends fall through to the regular publish path. - App.svelte wires three callback props through the new Sidebar component chain (
onStarToggle, contextual leave/close flows, slash-commandslashCommandevent listener). - USAGE.md MCP tool count corrected from 17 to 25 (catch-up for v0.3.3 doc-drift plus the 3 new v0.4.0 tools). Full tool-table audit deferred to a follow-up docs pass.
- Full pytest suite: 1268 passed (unchanged from v0.3.3; no Python regressions despite extensive backend additions in Steps 2.1/2.2/2.3).
- vitest: 745 passed (was 434 at v0.3.3 baseline). +311 net new tests across the 18 implementation steps.
- ruff check + format both clean.
pnpm buildproducessrc/claude_comms/web/dist/without warnings.- Svelte autofixer: zero issues attributable to any v0.4.0 step across all edited files.
- The previous single-list sidebar is gone. After upgrading, you'll see three sections immediately. Channels you're a member of land in Active; channels you've starred land in Starred; channels you can join (but haven't) land in Available.
- Pressing
Ctrl+Lis the fastest way to find a channel by name (opens the directory modal).Ctrl+Ncreates one. - Right-click any channel for the new context menu (Star, Mute, Mark read, Leave/Close, Channel info).
- Try
/helpin the composer for a list of slash commands. - "Close" semantics: closing a channel ARCHIVES it (preserves history, kicks members, blocks new sends). The Archived sub-tab in the directory modal lets you find archived channels. Unarchive via
comms_conversation_unarchive(MCP tool); UI surface for unarchive lands in v0.4.1. - Mute is per-device (localStorage, per Phil's Q4 decision). Cross-device mute sync lands in a future release if usage warrants.
Polish-and-safety release. Closes 4 UX showstoppers (no member-list section ever disappears; name changes propagate; over-limit sends are no longer silent; reconnect failures are actionable), the engineering critical from the v0.3.2 advisory (phantom-reactivity workarounds removed), and 8 long-tail UX gaps. Net 10 commits, 9 implementation steps + 1 prop-wiring follow-up. vitest grew from 375 → 434 (+59 tests, +16%) without a single pytest regression. Includes the Wave D race-condition incident that taught the orchestration framework about worktree isolation — surfaced as a process improvement, not a code bug.
- Phantom-reactivity workarounds removed (
web/src/components/ChatView.svelte,web/src/lib/mqtt-store.svelte.js). A 100 mssetIntervalpump inChatViewand twothis.messages = this.messagesself-assignment kicks inmqtt-storewere cargo-cult workarounds for a Svelte 5 reactivity bug that doesn't exist. The principal-engineer Svelte advisory validated this via the official MCP autofixer; Svelte 5's class-based$stateproxy tracks every read in a consuming$derived. ChatView now uses a single$derived(store.activeMessages); the self-assignments are gone. (Eng C-1) web/src/lib/mqtt-store.svelte.js_lencargo-cult reads removed from inside$derived.byblocks. Same misunderstanding of Svelte 5 dependency tracking. Comments now reference the actual$derivedsemantics. (Eng R-1)- Default display name no longer "Phil" for every new web client. Now
(unset)until either/api/identityreturns a non-empty name or the user sets one. App.svelte surfaces a one-line "Set a display name so others can recognize you" banner with a dismissible × (persisted underclaude-comms.nameBanner.dismissed). (UX G-43) - Sending while disconnected no longer silently drops messages. New
#pendingSendsFIFO queue (cap 100; drop-oldest withfailedmarker on overflow). On reconnect, drain in order. Each local message carries astatus: 'sending' | 'sent' | 'failed'field;MessageBubble.sveltevisualizes spinner / nothing / error icon + Retry button. Retry wires through App → ChatView → MessageGroup → MessageBubble tostore.retryMessage(id). (UX G-62) - Member list always shows three sections (Active / Online elsewhere / Offline), even when empty. Phil's "M-FIX" constraint — sections are stable UI surfaces, not data-conditional. Each empty section renders friendly inline copy in
--text-muted. Per-section collapse state persists inlocalStorageunder three namespaced keys; the legacyclaude-comms.offlineExpandedkey migrates on first read. - Sidebar star toggle now actually toggles.
handleStarTogglewas defined but never invoked from the channel-row template; hover-visible star icon now calls it withevent.stopPropagation()to prevent the channel-switch from firing.onStarToggleprop drilling from App.svelte added; store-fallback ensures the button works even if a parent forgets to pass the prop. (UX G-4) - Sidebar version label now derives from
web/package.jsonvia Vite's native JSON import, no more stale literal. Bumpedweb/package.jsonto 0.3.3 in lockstep. (UX G-5) - Sidebar
ustatusbinding now reflectsstore.connected/store.connectionErrorvia the same three-state palette asConnectionStatus.svelte(online green, connecting amber, offline red). Was a hardcoded "Online" string. (UX G-25) - Notification toasts are clickable. The card surface is now a
<button>; click →store.switchChannel(toast.channel)+ (if applicable)store.goToMessage(toast.messageId). Close-X handler usesstopPropagationto keep its semantics. (UX G-13) - Toast cap and coalesce. Maximum 3 visible toasts at any time. A 4th from the same channel coalesces in place to
"<sender> and N others sent messages"; 5+ collapse to a compact+N new in #channelpill. Cross-channel 4th evicts the oldest visible toast (FIFO). 5-second self-destruct resets on each coalesce. (UX G-14) - MessageInput over-limit no longer silently no-ops. When
inputValue.length > MAX_MESSAGE_LENGTH, the send button is disabled, the textarea border turns--ember-400, and an inline error message above the composer surfaces"Message too long ({over} over limit) - split or convert to artifact"with a "Convert to artifact" CTA that copies the textarea contents to clipboard (the full artifact-create flow lands in v0.4.x). (UX G-28) - ConnectionStatus retry button appears after 5 failed reconnects. Banner content shifts from indeterminate "Reconnecting to broker..." to actionable "Cannot reach broker - [Retry now] [Reload page]". Retry calls
store.connect(); Reload callslocation.reload(). Counter resets on successful connect. ARIA:role="status" aria-live="polite". Wired from App.svelte. (UX G-27) - Display-name changes now propagate to the daemon via the
comms_update_nameMCP tool. SettingsPanel'shandleNameChangedebounces (500 ms), guards onstore.connected, calls the newapi.updateName(key, newName)helper which POSTs JSON-RPC to the FastMCP/mcpendpoint, and reflects inlineSaving... -> Saved/Error: <reason>status. Success path clearsnameUnset+ updatesstore.userProfile.name; failure reverts the input. Closes the silent identity-divergence showstopper. (UX G-9)
#pendingSendsqueue + per-message status inmqtt-store.svelte.js, with publicretryMessage(messageId)method and three test-only seam methods (_installTestClient,_drainPendingSendsForTest,_pendingSendsLengthForTest).SidebaronStarToggleprop,ConnectionStatusonRetryprop,ChatViewonRetryMessageprop,MessageGrouponRetryMessageprop — explicit callback props through the App → child chain, replacing implicit store-method calls.api.updateName(key, newName)helper (web/src/lib/api.js). POSTs JSON-RPCtools/callto/mcp. 5-secondAbortControllertimeout. Handles bothresult.structuredContentandresult.content[0].textresponse shapes. Returns{success, name?, key?, error?}envelope.store.nameUnsetreactive flag — initializedtrue, cleared on successful identity fetch or successfulupdateName. Drives the App.svelte "Set a display name" banner.- Test coverage: +59 vitest specs across 8 new spec files (
mqtt-store-pending-sends,sidebar-fixes,toast-improvements,member-list,message-input,connection-status,prop-drilling,settings-panel). Each step's verification gate included Svelte MCP autofixer at zero issues.
MessageBubble.svelte— new visual states forsending/sent/failed. Failed state shows an icon + "Failed to send" + Retry link that firesonRetry(message.id).- Removed
web/src/lib/_alt/directory — abandonedmqtt-store-v2.svelte.jsexploration. Reader-confusion liability flagged in the principal-engineer advisory; gone now. (Eng O-2)
- Full pytest suite: 1207 passed (unchanged from v0.3.2; no backend changes in v0.3.3).
- vitest: 434 passed (was 375; +59 net new across 8 new spec files).
- ruff check + format both clean.
pnpm buildproducessrc/claude_comms/web/dist/without warnings.- Svelte autofixer: zero issues attributable to any v0.3.3 change across all edited files.
- The "Set a display name" banner appears on first load when
userProfile.name === '(unset)'. Dismiss is persisted; once you set a name, the flag clears. - If you see the banner after upgrading from v0.3.2 with a previously-set name in localStorage, your daemon-side identity didn't return a name. Set one via Settings → it'll propagate via the new
comms_update_namewiring. - v0.3.3 ships with a Wave D race-condition recovery in the commit log:
952f427carries Step 1.4's commit message but contains Step 1.5's files (toast improvements). Code state is correct; the misattribution is a permanent artifact per the "no amend" rule. Mitigation for future releases codified in the orchestration plan as worktree isolation for parallel agent waves.
Patch release. Closes Issues A and B from the v0.3.1 follow-up brief + extends the member list to a three-section model (active / online elsewhere / offline) with inline "in #X +N more" location chips for cross-channel visibility. Issue C (structured MQTT parse logger + empty-payload guard) already shipped in v0.3.1 — downstream users who still see the old Failed to parse MQTT message: floods are running the cached pre-v0.3.1 bundle and need a daemon restart plus a hard browser refresh.
- Issue A — multi-channel members no longer get pruned when viewing a single channel (
web/src/lib/mqtt-store.svelte.js:#fetchParticipants). Prior behavior: the 30-second REST poll for the active channel ran a global prune that deleted every local participant not in the active channel's response. A worker who was a member of both#generaland#svelte-workwould vanish from a phil-viewing-#generalsession the moment the REST poll for#generallanded — because the prune treated the active-channel snapshot as the source of truth for the global participant map. New behavior: the prune now skips any local participant who has at least one live connection (Object.keys(p.connections).length > 0). Ghosts from stale retained presence (the original reason the prune existed) still have empty connections and remain pruned correctly. Members of other channels stay visible because they have live MQTT connection records. - Issue B — new conversations created by another participant now appear in connected sidebars within ~1 second instead of requiring a full page refresh (
src/claude_comms/mcp_server.py:publish_conversation_event+web/src/lib/mqtt-store.svelte.js:#handleSystemConversation). Daemon now broadcasts conversation lifecycle events on a single non-retained MQTT topicclaude-comms/system/conversationswith payload{type, name, topic?, creator_key?, ts}wheretypeis one ofconversation_created/conversation_topic_changed/conversation_deleted. The web UI subscribes on connect and applies each event to the in-storechannelsarray (immutable reassignment for Svelte 5 reactivity).- Not retained by design — these are point-in-time deltas. Retaining them would cause every reconnecting browser to re-process every historical event, including ones whose effects have since been undone. The REST
/api/conversationssnapshot remains authoritative for cold start. - Wired in:
comms_conversation_createandcomms_conversation_update. Theconversation_deletedevent is defined in the wire-format but not yet wired in — there's notool_comms_conversation_deleteendpoint to hook from. Will surface when delete is added.
- Not retained by design — these are point-in-time deltas. Retaining them would cause every reconnecting browser to re-process every historical event, including ones whose effects have since been undone. The REST
- Three-section MemberList (
web/src/components/MemberList.svelte). Replaces the prior binary online/offline split with:- In #{channel} — members of the currently-viewed channel who are online. The primary "who can I address right now" view.
- Online (elsewhere) — participants connected globally but NOT joined to the active channel. Each row carries an inline
in #Xlocation chip showing the first (alphabetical) other channel they're a member of, plus a+N morecursor:helpchip when N > 0. Hovering the row surfaces a multi-linetitletooltip listing every channel they're in. Resolves the confusion Issue A's fix would otherwise introduce: now the user explicitly knows why a member is shown. - Offline — known participants with no live connections. Collapsed-by-default disclosure widget (
▶ Offline (N)) — state persisted inlocalStorageunderclaude-comms.offlineExpandedso users who care about offline context stay expanded. Each offline row carries alast seen Nm agorelative-time stamp computed frommember.lastOffline. - Within each section: alphabetical sort by name. Stable under churn; no visual jitter when many agents join / leave.
channelMembersreactive store state (mqtt-store.svelte.js). Per-channel{convId: {key: lastSeenTs}}map, populated by two sources:- REST poll of
/api/participants/{channel}now returns a per-memberconversationsfield (server-side change below). On each poll, every conv in each member's list gets the key recorded. - Live MQTT presence — the conversation is extracted from the topic (
claude-comms/conv/{conv}/presence/{key}) and passed to#handlePresence(msg, conversation)which records membership incrementally.
- REST poll of
/api/participants/{channel}includesconversations: string[]per member (mcp_server.py:get_channel_participants). Sorted list of every conversation each participant is a member of, fromParticipantRegistry.conversations_for. Drives the inline "in #X" chips without requiring a separate global endpoint.MqttChatStore.activeMembers/onlineElsewhere$derived.by()derivations +getMemberConversations(key)helper method — the data feeds for the three-section MemberList. The existingonlineParticipants/offlineParticipantsderivations are kept; they backonlineCountand a few other consumers.publish_conversation_eventmodule-level helper (src/claude_comms/mcp_server.py). Sibling topublish_mcp_presence_on_join. Acceptsevent_type,name, optionaltopic, optionalcreator_key. Constructs the canonical wire-format payload, publishes toclaude-comms/system/conversationsnon-retained, swallows publish-side exceptions. Public surface; importable for tests.tests/test_conversation_events.py-- 6 tests pinning the wire format (topic, type field discrimination, optional-field handling for each of the 3 event types) and the non-retained + exception-swallowing contracts.
- MemberList props signature — was
{online, offline, typingUsers, onShowProfile}; now{active, onlineElsewhere, offline, activeChannelName, getMemberConversations, typingUsers, onShowProfile}. App.svelte's invocation updated to feed the three new arrays from the store's derivations. - App.svelte 500ms snapshot pump — copies
store.activeMembers+store.onlineElsewhere+store.offlineParticipantsinto local$statearrays (length + connection-key fingerprint diff to avoid noisy reassignments). Replaces the prior single-array online + single-array offline pump. - Member-row rendering shared via Svelte 5 snippet —
{#snippet onlineRow(member, showLocation)}at the top of MemberList renders both the "In #channel" and "Online (elsewhere)" sections from one template. TheshowLocationflag toggles the inline location chip on for the elsewhere section.
The structured MQTT parse logger and empty-payload guard from v0.3.1 are already live in the wheel. If your DevTools console is still spamming Failed to parse MQTT message: SyntaxError: Unexpected end of JSON input, the stack trace will show an asset hash other than index-BWeN2qtA.js (v0.3.1) or the v0.3.2 hash. That means either:
- The daemon process is still pre-v0.3.1 in memory — run
claude-comms stop && claude-comms start --background --webafter the pip / pipx upgrade. - The browser cached the old
index.htmland is loading stale asset hashes — hard-refresh (Ctrl+Shift+R) or use an incognito window. After the daemon serves the newindex.html, the new asset hashes will load fresh.
The v0.3.1 fix correctly silences empty-payload "parse failures" (retained-clear cleanups) and logs structured [claude-comms] MQTT message parse failed objects on real failures. Confirmed by inspecting the published wheel.
Patch release. Two correctness fixes surfaced during smoke testing of MCP-driven worker agents on v0.3.0, plus a structured-logging upgrade for future bug-report quality.
- Bug 2 — MCP-joined participants (e.g.
svelte-worker) now appear in the web UI participant list reliably. Root cause:comms_joinover MCP published presence toclaude-comms/conv/{conv}/presence/{key}withretain=False. A web UI that connected AFTER the worker joined saw no retained presence and ghosted the worker until the 30s REST poll caught up. Web UIs that crashed or were reloaded mid-session lost workers entirely. Fixed by passingretain=Trueto both the conv-scoped and system-scoped presence publishes inmcp_server.py:publish_mcp_presence_on_join. The retained message survives until the worker disconnects (or the next daemon restart wipes broker-side retain state). - Latent
PublishFnprotocol mismatch (src/claude_comms/mcp_tools.py,src/claude_comms/presence.py). The protocol declared(topic: str, payload: bytes) -> Nonebut the real_do_publishimplementation has(topic, payload, retain=False). Pyright flagged this since 0.2.x but Python's duck typing accepted it at runtime. Became load-bearing onceretain=Truewas actually being passed at MCP-side presence call sites. Protocol updated to match the real signature;PublishSpytest fixture intests/conftest.pyupdated to record(topic, payload, retain)3-tuples (and the six test sites that unpack from spy calls were migrated to 3-element unpacks).
- Structured MQTT parse-failure logger (
web/src/lib/mqtt-store.svelte.js). Prior to this release every bad MQTT frame logged the same context-free"Failed to parse MQTT message: <error>"— undiagnosable. New#receiveMqttFramemethod:- Silently skips empty payloads (retained-clear publishes / presence cleanup — these are routine broker-state ops, not parse failures, but were polluting the previous log with
Unexpected end of JSON input). - On real JSON-parse failure, logs a structured object with
topic,payloadLength,payloadPreview(first 500 chars +[truncated, total=N]if longer),errorName,errorMessage, and an ISO timestamp. - Continues rather than letting the exception bubble — one bad frame never freezes the message stream.
- Silently skips empty payloads (retained-clear publishes / presence cleanup — these are routine broker-state ops, not parse failures, but were polluting the previous log with
- In-UI parse-failure banner (
App.svelte). Surfaces a soft-amber warning row when the parse-failure rate crosses ≥ 5 in any 30-second window. Pure observability; doesn't block the chat. Helps users notice connection issues / sender-side bugs without needing DevTools open. MqttChatStore.parseFailureRatereactive state +#parseFailureTimestampsprivate rolling window. Pruned on every update so stale entries age out without a separate timer.tests/test_mcp_presence.py(5 tests) — pins the retained-presence-on-join contract: publishes to both conv and system topics, every publish carriesretain=True, payload contains all fields the web UI's#handlePresencedestructures, helper swallows publish-side exceptions (best-effort).publish_mcp_presence_on_joinhelper (module-level inmcp_server.py). Extracted from the closure body ofcreate_server'scomms_jointool so it can be unit-tested independently of the FastMCP wiring. Public surface; callers passpublish_fnand the join'skey/name/type_/conversation.
PublishSpytest fixture (tests/conftest.py) —__call__now acceptsretain: bool = False;callslist records 3-tuples(topic, payload, retain). Six test sites acrosstest_artifact.py,test_mcp_tools.py,test_message_visibility.py,test_gaps_mcp_tools.py,test_conversation.pymigrated to 3-element unpacks.
- Full pytest suite: 1201 passed (1196 from v0.3.0 + 5 new presence tests), 0 failed.
- ruff check + ruff format both clean.
- End-to-end probe (fresh isolated daemon + MQTT subscriber + simulated MCP join) confirms presence is published to
claude-comms/conv/svelte-work/presence/{key}ANDclaude-comms/system/participants/{key}-mcp, both withretain=True, both carrying the correct wire-format payload (key,name,type,status: "online",client: "mcp",ts). - REST
/api/participants/{conv}returns the MCP-joined participant with the synthesized MCP connection (is_online: true), so the web UI's 30-second REST poll path also resolves the worker correctly even if the live MQTT path missed.
- After upgrade, restart the daemon (
claude-comms stop && claude-comms start --background --web). The fix lives in the daemon process; a stale pre-upgrade daemon will keep publishing non-retained presence. - Existing connected web clients may need a hard-reload (
Ctrl+Shift+R) or browser site-data clear to pick up the new bundledmqtt-store.svelte.js(the parse-failure logger + banner ship in the dist JS).
Minor release. Two distinct correctness fixes plus a polish piece. The headline change is participant registry persistence — the standing-agent use case (Claude Code agents that hold a key across daemon restarts) was structurally broken in 0.2.x and is now correct. Also lands the worker-src CSP fix (which the v0.2.3 web-UI brief flagged as the actual functional half of Bug 1) and the project's first favicon.
- Bug 3 — Participant registry no longer evaporates on daemon restart (closes the standing-agent use case). Prior to this change
ParticipantRegistrywas an in-memory dict — everyclaude-comms stop && startcycle silently invalidated every MCP-side participant key, so a Claude Code agent that joined and received key96052c22discovered after restart that its key was now "unknown" and had to callcomms_joinwithnameagain. Fixed by adding a SQLite-backedRegistryStoreat~/.claude-comms/registry.db. Schema covers participants, conversation memberships, per-conversation read cursors, and per-thread read cursors. WAL mode +synchronous=NORMAL+ foreign keys ON; foreign key cascades clean up memberships and cursors when a participant is purged. What is NOT persisted:Participant.connections— that's ephemeral presence state. Rehydrated participants come back offline (is_online == False) and re-online via MQTT presence +_ensure_mcp_connectionon next interaction. - Bug 1 — MQTT Web Worker no longer blocked by CSP. The daemon's CSP set
script-src 'self'correctly strict but never setworker-src— per CSP spec,worker-srcfalls back toscript-srcwhen unset, andscript-src 'self'does NOT permitblob:URIs. MQTT.js spawns its frame-parsing worker from a blob URL, so the worker was blocked. Symptom downstream: "Failed to parse MQTT message: SyntaxError: Unexpected end of JSON input" in DevTools console as the main-thread fallback choked on partial frames. Fixed by addingworker-src 'self' blob:tobuild_csp(). blob: URIs are same-origin by spec; this does not meaningfully widen the attack surface. New regression testtest_csp_worker_src_allows_blobpins the directive. - Bug 1 —
/favicon.ico404 closed. Wheel previously shipped no favicon, so every browser load 404'd on/favicon.ico. Addedweb/public/favicon.svg(the Wave Stack design: ember-on-transparent 5-bar voice-wave silhouette, designed to read at 16×16). Vite copiesweb/public/*to the bundleddist/root automatically;index.htmlgains a<link rel="icon" type="image/svg+xml" href="/favicon.svg">.
src/claude_comms/registry_store.py-- new module.RegistryStoreclass withopen(data_dir),load_all() -> RegistrySnapshot,upsert_participant,update_participant_name,add_membership,remove_membership,upsert_read_cursor,upsert_thread_read_cursor,purge_stale(before_iso),close. Thread-safe via internalthreading.Lock; supports use as a context manager.ParticipantRegistry(store=...)kwarg -- optionalRegistryStoreparameter on__init__. When provided, in-memory state is rehydrated from the store on construction and every mutating method (join,leave,update_name,update_cursor,update_thread_cursor,advance_thread_cursors_to) writes through atomically. Backward-compatible:ParticipantRegistry()with no kwarg keeps the legacy pure-in-memory behaviour the existing test suite relies on.tests/test_registry_store.py-- 19 unit tests covering schema creation, WAL/FK PRAGMAs, round-trip persistence for participants/memberships/cursors, FK cascade onpurge_stale, concurrent writes, idempotent close, and explicit assertion thatconnectionshas no schema column.tests/test_registry_persistence.py-- 10 end-to-end tests covering the integratedParticipantRegistry(store=...)path: participants survive restart, memberships survive restart, leave persists, name change persists, both cursor types persist, connections are NOT persisted (offline on rehydration),tool_comms_joinwith an existing key works after restart (the marquee bug-fix verification),tool_comms_conversationsreflects pre-restart memberships, andParticipantRegistry()without a store keeps pure-in-memory behaviour.mockups/favicons/-- the 25-design picker page (index.html+ individual SVGs undersvg/) used to select the v0.3.0 favicon. Kept in-tree for future favicon iterations; the picked design lives atmockups/favicons/svg/04-wave-stack.svgand is copied toweb/public/favicon.svgfor the actual build.web/scripts/screenshot-favicon-page.mjs-- Playwright helper that renders the picker page headlessly and dumps full-page + per-card screenshots for visual verification.
src/claude_comms/mcp_server.py:create_server-- now constructs aRegistryStore(at~/.claude-comms/registry.dbby default, overridable viaregistry.data_dirinconfig.yaml) and passes it toParticipantRegistry. The store is closed in the daemon'sfinallyblock so WAL checkpoints back into the main DB before process exit.README.md-- new "Where state lives" section above the CLI reference describes which paths under~/.claude-comms/survive restart, whyconnectionsis intentionally not persisted, and how to back up or resetregistry.db.
build_csp(default_config)now emits 9 directives includingworker-src 'self' blob:-- existing 17 CSP tests still pass, +1 for the worker-src assertion.- Wheel rebuild (
python -m build) packagesclaude_comms/web/dist/favicon.svg(1304 bytes) alongside index.html and the asset bundle. - Fresh-clone full pytest suite: 1197 passed, 0 failed (1167 baseline + 29 registry tests + 1 worker-src CSP test).
- ruff check + ruff format --check both clean across
src/andtests/. - A separate verification-run agent confirmed all CI gates green on the v0.3.0 working tree before tagging.
- No migration needed. The daemon creates
~/.claude-comms/registry.dbon first startup of v0.3.0; if you have agents still holding 0.2.x in-memory keys, they'll need to callcomms_joinwithnameonce more after upgrade to seed the new registry. After that, restarts are transparent. - Web UI must hard-refresh. The CSP header is read once at page load; a soft refresh may use the cached old policy. Use Ctrl+Shift+R / Cmd+Shift+R to force.
- Standing-agent pattern (worker that holds a key across many tasks) is now usable but not yet documented. A
comms_wait_for_messageMCP tool to make idle-worker polling cheap is on the roadmap for v0.4 — see the v0.3.0 PR thread for the design discussion.
Patch release fixing two production bugs that made the web UI unusable for any user who typed http://localhost:9921 (the natural URL) instead of http://127.0.0.1:9921, plus a PyPI gallery rendering fix.
- Web UI MQTT/REST connections blocked by CSP when loaded via
localhost(src/claude_comms/cli.py:build_csp). The web client builds its broker URL fromwindow.location.hostname, so loading the page viahttp://localhost:9921producesws://localhost:9001/mqtt. The daemon's CSP only listedws://127.0.0.1:9001, so the browser blocked the connection — the top banner stayed on "Reconnecting to broker..." forever and no realtime messages arrived. Fixed by expanding loopback bind addresses (127.0.0.1,localhost,0.0.0.0,::1) to both127.0.0.1ANDlocalhostvariants inconnect-src. Also now includeshttps://andwss://variants for future TLS deployment. - External Google Fonts dependency (
web/index.html,web/src/app.css). Inter font was loaded fromfonts.googleapis.com/fonts.gstatic.com, which required relaxingstyle-srcand addingfont-srcfor those CDN origins. Replaced with@fontsource-variable/interbundled into the wheel —font-src 'self' data:is now strict and the UI renders offline. Adds ~50 KB to the wheel but eliminates an external CDN dependency. - Gallery images broken on PyPI (
README.md). The 10 gallery image references used relative paths (mockups/gallery-XX.png), which GitHub auto-resolves but PyPI does not — PyPI's project description showed broken-image placeholders for the whole gallery. Switched to absolutehttps://raw.githubusercontent.com/Aztec03hub/claude-comms/main/mockups/gallery-XX.pngURLs. Renders on both GitHub and PyPI.
font-srcCSP directive ('self' data:). Was previously absent; fell back to thedefault-src 'self'value, which blocked the external Google Fonts. Now explicit + tight.tests/test_csp.py-- 17 unit tests pinning the CSP contract. Covers loopback alias expansion (parametrized over127.0.0.1/localhost/0.0.0.0/::1), default-config emission, LAN-IP bind,0.0.0.0bind,api_basereverse-proxy mode, and thecsp_extra_connect_srcescape hatch for Tailscale / public-DNS deployments. Hardening assertions: nounsafe-eval, no*wildcards in source lists, strictscript-src.web/e2e/csp-violations.spec.js-- Playwright regression guard. Asserts (1)Content-Security-Policyis delivered as a response header, not a<meta>tag, (2) the header contains bothlocalhostand127.0.0.1variants for broker + REST, (3) no CSP violations fire on the console during page load. Runs against the live daemon at127.0.0.1:9921.
web/index.html-- removed thepreconnecthints +<link>stylesheet load for Google Fonts. Inter is now self-hosted; comment in source explains why.
build_csp(default_config)emits a connect-src containing all 8 expected origins:http://127.0.0.1:9920,https://127.0.0.1:9920,http://localhost:9920,https://localhost:9920,ws://127.0.0.1:9001,wss://127.0.0.1:9001,ws://localhost:9001,wss://localhost:9001. Plus'self'.grep -E 'googleapis|gstatic'onsrc/claude_comms/web/dist/index.htmlreturns nothing -- the bundle is fully self-hosted.- Full pytest suite:
0 failed, 1166 passed(1149 pre-existing + 17 new CSP tests). - ruff check + ruff format: clean across
src/andtests/.
Reconnecting to broker...banner timeout. After N failed reconnect attempts (5? 10?), the banner should switch to a definitive "Cannot connect to broker -- check the daemon logs" with a manual retry button. Indefinite "reconnecting" is user-hostile. Tracked as a separate web-UX task; not part of v0.2.3 because the CSP fix already resolves the headline symptom.
Docs-only patch release. Re-publishes the rendered README so PyPI's project page picks up README fixes that wouldn't otherwise reach the cached upload from 0.2.1. No code changes.
- CI badge in README -- switched from GitHub's native
actions/workflows/ci.yml/badge.svg(which has a 5-minute cache and showedfailingon PyPI for ~5 min after the v0.2.1 push while the lint job was being fixed) to ashields.iobadge with an explicitbranch=mainfilter and a click-through link to the workflow page. Independent cache layer + visible link target. - README rich-text rendering -- two occurrences of an over-escaped backtick pattern (
`\`code\) rendered on PyPI as the literal text `\code\` instead of the intended inline-code chip. Fixed by wrapping with double-backticks per CommonMark (`code```), which is the proper way to embed literal backticks inside an inline code span. TouchedREADME.mdlines describing the Web UI's rich-text rendering and the `RichText.svelte` parser. - CHANGELOG retroactive backtick fixes -- same over-escape pattern in the
web/src/lib/rich-text-parser.jsentry (under v0.2.0) and the "Format help button" entry (older release) updated for consistency. CHANGELOG.md is repo-only / not rendered on PyPI but the fix preserves the audit trail. - Name capitalization in README -- the project credit at the bottom of the README read "Phil Lafayette" but Phil's canonical capitalization is mid-cap: "Phil LaFayette" (matches the
authorsfield inpyproject.tomland git author on every commit). Fixed.
- README audited for any remaining
\\\`` / over-escape patterns. The*markers in the MCP-tools table (``name\* `` for "required parameter") are correct markdown idiom and render asname`* with a literal asterisk -- intentional, left as-is. grep -rn 'Lafayette'across*.md/*.toml/*.json/*.py/*.yml/*.yamlreturns nothing.
Patch release. v0.2.0 shipped to PyPI cleanly (web/dist bundled, daemon binds, install round-trip works) but had a cosmetic bug: claude-comms --version reported 0.1.0 because the in-code constant lagged pyproject.toml. Also lands CI hygiene work that had been queued behind the v0.2.0 publish dance.
__version__now reads from package metadata viaimportlib.metadata.version("claude-comms")(src/claude_comms/__init__.py). The hardcoded__version__ = "0.1.0"constant is gone;pyproject.tomlis the single source of truth and the CLI's--versionflag always reflects the wheel's version. Falls back to"0+unknown"when the package isn't installed (e.g. running tests from a fresh clone beforepip install -e .).- 31 pre-existing ruff errors + 22 files needing
ruff format-- CI lint job had been red for 5+ runs. Real bug fixed intui/app.py:666(backslash in f-string expression -- valid syntax only on Python 3.12+, broken on the project's 3.10/3.11 minimum). Bulk auto-fixes for 28 unused imports + 1 duplicateMessageBubbleimport intests/test_tui.py. Vestigialkey_bobintests/test_conversation.pyreplaced with a side-effect-only registration + comment. - 93 async-await test failures -- 11
tool_comms_*functions insrc/claude_comms/mcp_tools.pywere converted from sync to async without the test suite being updated. Subagent mechanical pass: addawait, mark functionsasync def, decorate with@pytest.mark.asyncio. Touches 7 test files / 29 test classes. One sync-helper conversion inTestAllCommsToolsWithMockPublish._setup. Oneasyncio.run()inside a worker thread intest_concurrent_joinsto preserve threaded-contention semantics. - 5 stale assertions surfaced after the await pass -- 3 tests asserted
client=="unknown"/status=="offline"from the pre-_ensure_mcp_connectionera (commit04a0501). 1 test asserted the 7-key Message schema (now 13 keys with mentions + thread_*). 1 test (test_targeted_to_self) was the only behavior assertion: it expected self-targeted sends to succeed, but the recipient resolver drops the sender. Renamed totest_targeted_to_self_is_rejectedand updated to assert the resolver's contract.
- CI workflows opt into Node 24 ahead of GitHub's deprecation deadlines.
env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"added at the workflow level to bothci.ymlandrelease.yml. Node 20 becomes a removed runtime on 2026-09-16; Node 24 becomes the default on 2026-06-02. Opt-in is harmless once Node 24 is default (the variable becomes a no-op).
pip install --no-cache-dir claude-comms[all](no--pre) from PyPI resolves the 0.2.0 wheel, and after this patch ships will resolve 0.2.1.claude-comms --versionin a freshpip install -e .of this branch reports0.2.0(the current pyproject value); the next install of 0.2.1 will report0.2.1without any code edit.- Full pytest suite:
0 failed, 1149 passed. The 93 pre-existing async-tool-call failures + the 5 surfaced stale assertions are all resolved. No new test was deleted, skipped, or xfail'd. - CI lint job passes for the first time in 5+ runs (verified locally; cloud run will confirm).
End-user impact: pipx install "claude-comms[all]" (and pip install from PyPI) now drops a daemon that serves the Svelte web UI out-of-the-box -- no Node toolchain, no manual pnpm build, no Web UI dist not found... -- skipping log line on the consumer machine. Closes the gap where a downstream user installed the package and discovered the daemon had no web UI to serve because the wheel never bundled the built Svelte assets.
- Daemon's web-asset path resolution (
src/claude_comms/cli.py:1140-1148) -- wasPath(__file__).resolve().parent / "../../web/dist", which walks UP two directories from the installed package and only ever resolved correctly from an in-tree source checkout. From a pipx install this expanded to<venv>/lib/python3.12/web/dist, which no install layer ever produced. Replaced withimportlib.resources.files("claude_comms").joinpath("web", "dist")-- zip-safe and stable across pip / pipx / editable / frozen installs.
hatch_build.py(new file at repo root) -- Hatch custom build hook. On every wheel/sdist build it ensuressrc/claude_comms/web/dist/index.htmlexists; if missing, it shells out topnpm install --frozen-lockfile && pnpm buildinsideweb/and fails loudly with a toolchain-install message ifpnpmis absent. Already-built dist short-circuits the hook so CI prebuilds and dev incremental builds don't re-pay the install cost..github/workflows/release.yml-- tag-driven PyPI publish workflow. Triggered bypush.tags: ['v*']. Builds wheel + sdist viapython -m build(which transparently fireshatch_build.py), verifiesclaude_comms/web/dist/index.htmlis in the wheel with apython -m zipfile -l | grepgate, then publishes viapypa/gh-action-pypi-publish@release/v1using OIDC trusted publishing -- no long-lived API tokens in repo secrets. Includes a security note that the workflow does not consume anygithub.event.*untrusted inputs.web/.npmrc-- pnpm config:onlyBuiltDependencies=[esbuild, @tailwindcss/oxide]allowlist (pnpm 10+ blocks postinstall scripts by default for supply-chain safety) andconfirmModulesPurge=falsefor CI / no-TTY contexts.web/pnpm-lock.yaml-- the npm-to-pnpm migration deterministic lockfile.
pyproject.toml-- version bumped0.1.0 -> 0.2.0. Build-systemrequires = ["hatchling>=1.21"](was unpinned). Added[tool.hatch.build.hooks.custom] path = "hatch_build.py". Wheel target gainsartifacts = ["src/claude_comms/web/dist/**"]so the gitignored build output is opted into the wheel. New[tool.hatch.build.targets.sdist]declaring explicit include/exclude (sdists includeweb/source so a source install can rebuild the UI; they excludeweb/node_modules,web/.svelte-kit,web/dist, andsrc/claude_comms/web/dist).web/vite.config.js--build.outDirchanged from'dist'to'../src/claude_comms/web/dist'. Vite now emits straight into the Python package so wheel + editable installs both find assets at the sameimportlib.resourcespath. AddedemptyOutDir: truebecause the outDir lives outside Vite's project root.web/package.json-- migrated from npm to pnpm: removedpackage-lock.jsonreferences, added"packageManager": "pnpm@11.1.1",engines.pnpm: ">=11", and a top-levelpnpm.onlyBuiltDependenciesmirror of the .npmrc allowlist. Added@shikijs/langs@3.0.0as a direct dependency (was relying on npm flat-hoisting; pnpm's strict isolation requires it explicitly becausesrc/lib/markdown.jsdoes deep imports like@shikijs/langs/typescript).build:checkscript switchednpm runtopnpm run..github/workflows/ci.yml-- collapsed the legacybuild-webjob into thetestjob (Node + pnpm setup +pnpm buildbeforepip install -e, so the editable install short-circuits the hook). Added a newbuild-wheeljob that runspython -m buildand verifiesclaude_comms/web/dist/index.htmlis in the wheel via azipfile -l | grepgate, then uploadsdist/as an artifact. Oldweb-distartifact (uploading the rawweb/dist/from npm) is gone..gitignore-- addedsrc/claude_comms/web/dist/(Vite's new outDir, intentionally tracked only viaartifactsin pyproject.toml).- README Quick Start step 1 -- rewritten with three explicit install paths: stable PyPI (
pipx install "claude-comms[all]"-- no Node required), latest from git (compiles UI at install, requires Node 20+/pnpm 11+), and local-dev (pip install -e+pnpm devfor Vite HMR). Replaces the prior single-line snippet that didn't mention the toolchain requirement of a source install.
- Fresh-venv install (
pip install dist/claude_comms-0.2.0-py3-none-any.whlinto a cleanpython3 -m venv) resolvesimportlib.resources.files("claude_comms").joinpath("web", "dist", "index.html")to a real file (1021 bytes, content-matched to the sourceindex.html). - Wheel contents inspection:
python -m zipfile -l dist/claude_comms-0.2.0-py3-none-any.whl | grep web/distshowsindex.html+ 13assets/*.js/.css/.mapentries baked in. Total wheel size 1.5MB. - Editable install (
pip install -e .) resolves the same path to<repo>/src/claude_comms/web/dist/, so dev flow ("HMR viapnpm dev" or "production rebuild viapnpm build") works without manual copy steps. - Test suite delta: zero new failures attributable to this change (verified by sampling failing tests -- all are the pre-existing async-
tool_comms_jointech-debt cluster tracked separately).
Documentation only -- no code or behavior change. Surfaces the MCP-registration instructions from USAGE.md into the README's Quick Start so external readers (other Claude Code users wanting to use claude-comms as an MCP) discover them without digging into USAGE.md.
- README Quick Start step 6 rewritten as "Register the MCP server with Claude Code". Replaces the prior outdated snippet (which mixed
command/argsstdio fields with an HTTPurland dropped the/mcppath) with three explicit install paths (project-scoped.mcp.json, user-wideclaude mcp add ... -t http, manual.mcp.jsonin another project), the:9920/mcpURL gotcha, the 22-tool subagent permission allowlist for~/.claude/settings.json, a verify step (/mcpslash command, tab-completion probe,comms_joinround-trip), and a network-considerations note (loopback is the security boundary; LAN/Tailscale only on trusted networks). Mirrors the existing long-form section inUSAGE.md-- USAGE.md remains the canonical home, the README now carries a discoverable short version.
Headline change: end-to-end threaded replies across the server, MCP read + MQTT fanout, and web UI lanes, capped at depth-2 (a reply may target a top-level message but not another reply). Plus a small surgical mention-color polish that pulls mention-other out of grey and back into the ember family in both web themes and the TUI. Threading work shipped via three coordinated subagents (ember/phoenix/sage) against the threaded-replies-plan artifact (v1 -> v5 with adversarial review baked in).
Messagethread metadata -- five optional Pydantic fields atmessage.py:97-134:thread_root_id,thread_reply_count,thread_last_ts,thread_last_author,thread_participants. Derived/read-side state (not user-supplied); populated by the broker dispatcher on reply ingest and rebuilt at JSONL replay time.comms_sendreply_tokwarg -- depth-2 thread send. Server validates parent existence, same-conversation, depth-2 (parent's ownreply_tomust be null), and non-system-parent. On reply ingest the dispatcher mutates the root dict to maintain the fivethread_*fields and additionally publishes toclaude-comms/conv/{conv}/threads/{root_id}(non-fatal on failure).comms_thread_readMCP tool (#22) --comms_thread_read(key, conversation, root_id, count?, since?)returns{conversation, root, replies, count, has_more}.rootis always populated regardless ofsinceso incremental fetches never lose context.repliesis the flat depth-2 list of messages whosereply_to == root_id, visibility-filtered forkey. Side-effect: advances a per-thread read cursor.comms_readtop_level_onlykwarg -- when True, filters to thread roots + untyped top-level messages and decorates each retained root with at least one reply with athread_summary: {reply_count, last_ts, last_author}field synthesized from the flat thread metadata. UI passes True for the channel feed.comms_checkthread_unreadmap -- each per-conv summary entry now also carries athread_unread: {root_id: count}map for any threads with unread replies, computed against the per-thread read cursors.mark_seen=Trueadvances both the channel-level read cursor AND every relevant per-thread cursor in lockstep to the latest visible reply.MessageStore.find_by_id(conv_id, msg_id)andMessageStore.update_thread_metadata(...)-- lock-safe accessors on the in-memory store.find_by_idreturns the live dict reference (the same object decorated withthread_summaryat read time).broker.py:_rebuild_thread_metadata(store)-- second-pass JSONL replay function wired intoreplay_jsonl_logsso daemon restart reconstructs every root'sthread_*fields from the flat reply records.ParticipantRegistry._thread_read_cursorskeyspace plusupdate_thread_cursor/get_thread_cursor/thread_cursors_for/advance_thread_cursors_tomethods. Cursors are keyed(participant_key, conversation, root_id) -> ts.- Per-thread MQTT topic -- replies fan out to
claude-comms/conv/{conv}/threads/{root_id}in addition to the mainmessagestopic, letting a thread-focused viewer subscribe just to the thread it cares about. Publish failure is logged but non-fatal. web/src/lib/reply-parser.js-- pure/reply <message_id> <body>parser. Surface-shape UUID v4 validation (8-4-4-4-12lowercase hex with hyphens); the server is the authority on existence/depth-2/non-system-parent. Returns{replyTo, body, error}. Mirrors thedm-parser.jsshape so the threading grammar stays orthogonal to the whisper grammar.web/tests/reply-parser.spec.js-- 20 Vitest tests covering missing trigger, missing id, malformed id, empty body, leading-whitespace tolerance, tab-as-separator, body-with-internal-spaces.- Web
mqtt-store.svelte.jsthread state --threadSeenCursors($statemap ofroot_id -> latest acknowledged reply ts),markThreadSeen(rootId)method,activeChannelReplies$derived(filtersactiveMessagesto depth-2 replies in the active channel), persistence tolocalStorageunderclaude-comms-thread-seen-cursors. The existingactiveMessages$derivedis now top-level-only and splicesthread_unread_countonto each root from the seen-cursor map. - Web
MessageBubblethread chip -- driven bythread_reply_count(singular/plural-aware), optional "last by @author" line,class:has-unreadaccent whenthread_unread_count > 0. Click/Enter/Space opens the ThreadPanel. - Web
MessageInput/replydispatch --parseReplyimport + branch alongside the existing/dmdispatch, identical composer-error UX. - Web
App.sveltethread wiring --handleOpenThreadcallsstore.markThreadSeen(message.id); ThreadPanelmessagesprop readsstore.activeChannelReplies. tests/test_threaded_replies.py(16 tests) -- server-side:Message.thread_*round-trip,find_by_id+update_thread_metadata,_rebuild_thread_metadatareplay,tool_comms_sendreply_tovalidation matrix.tests/test_threaded_replies_read.py(23 tests) -- read-side:tool_comms_thread_read,top_level_only+thread_summary,thread_unreadmap, lockstepmark_seenper-thread cursor advance, per-thread MQTT fanout (incl. non-fatal failure path).tests/test_message.py::test_json_keysupdated to expect the five newthread_*keys in themodel_dump_json()output.
MessageBubble.sveltethread chip -- upgraded from the deadthread_countplaceholder to the livethread_reply_count(singular/plural), with an optional "last by @author" line and a.has-unreadaccent. Driven by the broker-dispatcher / replay-rebuiltthread_*fields rather than client-side counting.mqtt-store.svelte.js: activeMessages-- now$derived.byfiltering to top-level messages (reply_to === null) and splicingthread_unread_countonto each root from the per-thread seen-cursor map. The depth-2 reply feed lives in the newactiveChannelReplies$derived. Non-breaking for top-level rendering; ThreadPanel switches to the new derivation.- Mention-other color tokens -- both dark and light theme
--mention-other-bg/--mention-other-fgmoved out of grey and into the ember family. Dark:rgba(245,158,11,0.12)+var(--ember-400)(wasrgba(168,160,152,0.14)+var(--text-secondary)). Light:rgba(217,119,6,0.10)+var(--ember-500). The TUIMENTION_OTHER_STYLEinchat_view.pyflipped from"#8a8a8a"to"#f59e0b"for parity. All three mention tiers (selfloud,othersofter, legacy chip) now share the ember palette and differentiate via weight + alpha rather than hue.
- Threading is intentionally flat (depth-2). A reply may target a top-level message; a reply may not target another reply. The server enforces this on every send, returning a validation error.
- Thread metadata is derived state, not user-supplied. Wire-format messages still ship with
thread_*fields asnullfrom the client; the server populates them on the root dict during dispatch / replay. - Per-thread MQTT publish failure is non-fatal. The primary publish to the conversation
messagestopic is the source of truth; the per-thread topic is a fanout convenience. - TUI threading is not yet exposed. The MCP + web lanes shipped this batch. Replies arrive on the TUI's channel feed as ordinary messages; a TUI
/replyparser + ThreadPanel is a follow-up.
- Three coordinated implementation subagents -- ember (server lane), phoenix (MCP read + MQTT lane), sage (web UI lane) -- shipped against a single
threaded-replies-planartifact (v1 -> v5 with adversarial review baked in). The plan structure (per-lane scope + locked grammar in §6 + Pydantic schemas in §4.1) let the three lanes integrate cleanly without follow-up correction passes.
Headline change: a clean break separating broadcast highlights (mentions) from private whispers (recipients) on every message. Plus reactions, working/status indicators, backtick rendering with markdown emphasis, presence resurrection for swept MCP connections, and a server-authoritative stale-offline-participant prune. Spec lives at plans/mentions-vs-whisper-separation.md v6 (4 adversarial review rounds, converged at 0 critical / 0 major).
Message.mentions: list[str] | Nonewire field with hex-key validator mirroring_validate_recipientsatmessage.py:95-105. Pydantic v2 defaultmodel_dump_json()symmetrically emits"mentions":nullalongside"recipients":null.comms_sendmentionskwarg -- broadcast highlight intent. Visible to all conversation members; named users get a notification cue; does NOT restrict visibility. Independent ofrecipients; the two may be combined (whisper-with-named-highlights).comms_checkmark_seenkwarg -- opt-in cursor advance after the response is built. Returnedtotal_unreadreflects the PRE-advance count, so callers see what they acknowledged. Defaults toFalseto preserve peek-only semantics.resolve_for_mentionsregistry method -- new variant ofresolve_recipientsthat hex8-validates against the global participant registry.resolve_recipientsis unchanged; lenient hex8 pass-through preserved.comms_reactMCP tool -- add, remove, or toggle (default) emoji reactions on a message. Persists to a per-conversation reactions log, broadcasts on a dedicated reactions topic. Rate limits: 30 events per actor per minute per conversation, max 10 distinct emojis per actor per message. No-op operations return{"status": "no_op"}.comms_reactions_getMCP tool -- list current reactions on a message as{"reactions": {emoji: [actor_key, ...]}}.comms_status_setMCP tool -- ephemeral activity signal (e.g.,thinking,reading,drafting). TTL default 30s, hard cap 300s. Throttled to one update per 2s; bursts dropped (last-write-wins). Auto-expires on disconnect or sweep.comms_status_clearMCP tool -- idempotent clear of the active activity signal.src/claude_comms/reactions.py--ReactionandReactionEventPydantic models,ReactionsStoreclass with add/remove/toggle, dedup, and rate-limit enforcement.src/claude_comms/working_indicator.py-- activity-signal decorator + sweep machinery forcomms_status_*.PresenceManager.ensure_connection()-- resurrects MCP connections that were swept while still active, fixing the case where an idle MCP session was reaped server-side but the client had a live token._ts_after()helper inmcp_tools.py-- timezone-aware cursor comparison fixing a mixed-timezone string-compare bug that was filtering out otherwise-visible messages./dm @user[, @user2] bodyslash command -- composer parses recipient tokens against the §6.2-A grammar (whitespace OR comma OR comma+whitespace separates tokens; tokens end at first non-@<name>), resolves names to keys viastore.participants, and sends a whisper. Wirerecipientsalways carries keys.- Profile-card "Send DM" button -- pre-fills the composer with
/dm @<name>via store-mediatedcomposerPrefill, watched byMessageInput.sveltethrough$effect. Replaces the previous fragiledocument.querySelector+input.value =pattern. web/src/lib/rich-text-parser.js-- pure parser splitting message bodies into segments: plain text, inline`code`chips, triple-backtick fenced blocks, bold**text**, italic*text*, strikethrough~~text~~. DrivesRichText.svelte.web/src/lib/compose-overlay-segments.js-- composer overlay segmenter so backticked text colors live as you type without disrupting the textarea/mirror alignment.web/src/components/RichText.svelte-- segment renderer used byMessageBubble.svelte.web/src/lib/dm-parser.js--parseDMslash-command parser (single-responsibility; intentionally separate frommentions.jsautocomplete).- Mention render branches in
MessageBubble.svelte:parseBody--mention-self(bold + amber +.has-self-mentionborder accent on the bubble) for messages calling out the viewer;mention-other(quiet grey) for everyone else's mentions; legacy.mentionchip preserved for whispers, sender-self, and unkeyed mentions. CSS tokens--mention-self-bg/-fg/-border,--mention-other-bg/-fgdefined inapp.css. - TUI render parity -- self-mentions render bold + amber with a
▎glyph in the left margin and abox.HEAVYPanel border on the bubble; other-mentions render dim. Whisper bubble gates onrecipientsonly, independent ofmentions. Sender-self special case suppresses the loud chip on your own bubble. - Working / status indicator UI -- amber dot with the active label next to a participant's name in the member list (web + TUI), fading on clear/expiry.
- Member-list emoji-picker integration for adding reactions from the web UI.
tokensToMentionshelper inmqtt-store.svelte.jscompanion to the now-deprecatedtokensToRecipients(retained as deprecated alias for one release).tests/test_message_visibility.py(20 tests) -- send/visibility matrix per §10 of the plan: broadcast, mentions-only, whisper, whisper-with-mentions, sender-key dedup, hex8 validation, legacy fixture coercion,mark_seencursor-advance.tests/test_reactions.py(26 tests) -- model validators, store CRUD, rate limits, dedup,comms_react/comms_reactions_getintegration.tests/test_status.py(27 tests) -- working-indicator decorator,comms_status_set/comms_status_clear, TTL expiry, throttle, sweep, broadcast scope.- TUI test expansion in
tests/test_tui.py-- self-vs-other mention parity,box.HEAVYwhisper bubble, working-indicator badge. - Web tests --
web/tests/rich-text-parser.spec.js,compose-overlay-segments.spec.js,composer-backtick.spec.js,dm-parser.spec.js,message-bubble-mentions.spec.js,message-input-mentions.spec.js(Vitest); plusweb/e2eupdates.
comms_sendresolves recipients viaresolve_recipientsand mentions viaresolve_for_mentions-- separate paths preserve the lenient hex8 pass-through onrecipientswhile strictly validating mention keys against the global registry (drops stale keys, prevents future-collision agent-trigger bugs).- Sender-key dedup discipline --
recipients: dedup at composer + server (defense in depth).mentions: dedup at composer only (UX); NOT deduplicated server-side. [@name]body prefix policy -- the server prepends a[@name1, @name2]prefix ONLY whenrecipientsis non-empty. Mentions-only sends never get a server-injected prefix.comms_checktotal_unreadcount -- now applies_is_visiblefiltering (whispers addressed to others are excluded). Co-shipped with themark_seenopt-in. Cross-deploy effect: already-running agents will see lower counts than before for conversations containing whispers addressed elsewhere; this is the intended state.mqtt-store.svelte.js: sendMessagesignature migrated to options-object:sendMessage(message, channel, { mentions, recipients }). JavaScript silently destructures arrays into option-objects, demoting whispers to broadcasts on partial deploys, so the store signature change ANDMessageInputcall-site update land in the same commit per the plan's atomicity constraint.- MCP tool docstring at
mcp_server.py:656updated to document the broadcast-vs-whisper distinction. - Member list (web + TUI) -- now renders the working-indicator badge and prunes stale offline participants (server-authoritative + retained-MQTT-presence cleanup in
mqtt-store.svelte.js). - Markdown inline emphasis --
RichText.svelteandMessageBubble.svelterender**bold**,*italic*,~~strike~~.
- Mixed-timezone cursor comparison --
mcp_tools.pywas string-comparing ISO timestamps with mismatched offsets, intermittently filtering out otherwise-visible messages fromcomms_read/comms_check. New_ts_after()helper normalizes both sides before comparing. comms_checkinvisible-message overcount -- previously counted whispers addressed to others intotal_unread; now applies the same_is_visiblefilter ascomms_read(R2-M1 defect-fix).- Phantom offline participants after daemon restart -- web member list lingered on stale offline entries from retained MQTT presence after a daemon swap. Now pruned server-authoritatively with retained-presence cleanup in
mqtt-store.svelte.js. - Swept MCP connection invisibility -- when an idle MCP session was reaped server-side, subsequent tool calls from a still-live client token surfaced as a participant disappearance.
PresenceManager.ensure_connection()now resurrects the session. - Sender-self visibility invariant test added --
recipients=[other](sender NOT in list) is visible to sender via_is_visible's sender-key check. Locks the assumption so future_is_visiblechanges (e.g., mute lists) don't silently break the sender-key dedup invariant.
- External MCP
comms_sendcallers passingrecipientscontaining ONLY the sender's own key now receive a"None of the specified recipients could be resolved"error (was silently a no-op self-DM). Multi-recipient calls including the sender's key continue to succeed with the sender dropped. - Pre-cutover messages predating the mentions/whisper split keep their
recipientsfield as whisper-only -- no migration was applied; this is by design (clean break per §9 of the plan). Pre-cutover[@name]body prefixes that look like mentions are still whispers. - TUI write-side asymmetry (v1) -- the
mentionsfield is empty for TUI-originated messages. TUI free-typed@nameproduces broadcasts withmentions=null. The existing[@name]body-prefix path continues producing whispers (recipients-set). v2 may add a TUI/dmparser.
plans/mentions-vs-whisper-separation.mdv6 (826 lines, 4 adversarial review rounds, convergence trend 25 -> 11 -> 6 -> 6 with 0 critical / 0 major at convergence).
Production-grade overhaul of the @mention UX in MessageInput.svelte. Replaces the blocking, ungroomed dropdown with an overlay-based, non-blocking, ghost-suggesting, implicit-commit autocomplete that matches what users expect from Slack, Discord, and Linear.
- Overlay pattern -- transparent
<textarea>(caret + input events) layered over a colored mirror<div>(confirmed mentions in ember, ghost-suggestion in faint italic). Pixel-perfect alignment via shared font/padding/line-height + scroll sync. - Mention-token data model -- confirmed mentions are
{start, end, name, key}records, decoupled from text. Editing punctuation around a token never breaks identity. - Three-pass edit reconciliation on every keystroke: offset-shift on insert/delete outside tokens, invalidate on overlap, sanity-check via text equality. Robust against paste, drag-drop, and programmatic mutation.
- Implicit commit (Tab is optional) -- exact-match queries auto-commit via three triggers: word-terminator (space, comma, period, etc.) for instant commit, 200ms idle debounce for silent commit, cursor-move-away. Tab still works for decisive users. Send synchronously commits any pending implicit match before building recipients.
- Visual ember coloring leads the formal commit by ~200ms -- as soon as exact match is detected the matched range turns ember in the overlay, giving instant feedback. If the user types more and breaks the match, color reverts. What you see is what you send.
- Online-first sort, prefix-match filter, cap of 7 candidates -- replaces the previous "show every participant" behavior.
- Ghost text -- the unentered remainder of the highlighted candidate appears as faint italic after the cursor (
@claude-testi[ng]), so users always see what Tab will commit. - Self-mention prevention -- the current user is excluded from candidates.
- Backspace-to-edit re-targeting -- editing a committed mention's name invalidates the token and re-spins the suggestion in the same parsing pass; user can keep typing or Tab to re-commit. No dialogs, no blocking.
- Send-time recipient resolution --
mqtt-store.svelte.js'ssendMessage()now accepts an optionalrecipientsarray (third arg).MessageInputwalksmentionTokensand passes their keys; backwards compatible (null/empty preserves old behavior). - Accessibility -- dropdown uses
role="listbox"withrole="option"rows andaria-selected. Textarea exposesaria-activedescendantso screen readers announce the highlighted candidate without focus moving. Confirmed mention spans getaria-label="mentioning {name}". Ghost isaria-hidden. - IME composition -- mention parsing skipped during
compositionstart-compositionendso CJK input doesn't thrash candidates mid-character.
- New
web/src/lib/mentions.js(pure helpers + segment walker, 36 unit tests) - New
web/tests/mentions.spec.js(logic) +web/tests/mention-input.spec.js(component, 13 tests) - New
plans/mention-autocomplete-revamp.md(design doc with worked examples) - Modified
web/src/components/MentionDropdown.svelte(presentational, no kbd ownership) - Modified
web/src/components/MessageInput.svelte(overlay + state + commit logic) - Modified
web/src/lib/mqtt-store.svelte.js(sendMessagerecipients arg)
171 / 171 Vitest passing (49 new). Build clean. svelte-autofixer clean on all modified files.
Three post-ship fixes after first-run verification.
[object Object]author bug fixed -- version dropdown and artifact list rows now renderauthor.name(withauthor.keyfor participant color) instead of stringifying the{key, name, type}object. 4 sites inArtifactDetailHeader.svelte+ 2 sites inArtifactList.svelte. Addedweb/tests/artifact-author-rendering.spec.js(5 defensive tests).- Resizable panel -- drag the left edge to resize between 320 px and min(900 px, viewport - 200 px). Uses Pointer Events API (touch + pen friendly), ember-amber hover tint,
role="separator"+aria-orientation="vertical"+aria-valuenow/min/maxper WAI-ARIA "Window Splitter". Keyboard-accessible (Tab to handle, Left/Right nudges 16 px, Home/End jump to bounds). Width persists inlocalStoragekeyclaude-comms:artifact-panel-width. Slide-in animation suppressed during drag for zero-latency tracking. - Body scroll fixed -- long artifacts now scroll within the body area while the header stays pinned. Root cause: the flex-child
min-height: autodefault was resolving to intrinsic content height, preventingoverflow-y: autofrom taking effect. Addingmin-height: 0on.artifact-content-area+flex-shrink: 0on the header resolved it.
Tests: 122 / 122 Vitest passing. Build clean.
Ten-improvement upgrade to the artifact subsystem: real-time sync, diffing, in-browser editing, markdown rendering, starring, and a full security/accessibility/test overhaul. Implemented across 15 tasks in 4 batches.
- Real-time panel refresh --
artifactsDirtystore counter with debounced fetch; panel auto-updates when create/update/delete events flow through chat, concurrency-safe against active editing - VSCode-style diff view -- side-by-side or unified diff between any two versions with line numbers, colored
+/-gutters, and inline char-level highlighting via jsdiff (diffLines+diffWords). Non-color gutter glyphs for accessibility. - Per-version author display -- version dropdown now shows author name (participant-colored) + relative timestamp + summary
- Edit-in-place from the web UI -- textarea replaces content area with autoresize attachment, Save/Cancel controls, Cmd+Enter / Esc shortcuts, dirty-state confirm, and a remote-update banner for collaborative conflict handling
- Markdown rendering --
plan/doctypes render as markdown viamarked+ DOMPurify (strict sanitize config + external-image interception) with Shiki syntax highlighting using acssVariablestheme mapped to the Carbon Ember palette - Star/pin artifacts -- hover-to-reveal star button, dedicated STARRED section, identity-scoped localStorage (
claude-comms:${identityKey}:starred-artifacts), reconcile on mount, 500-entry cap - Polished empty state -- FileText icon, copy, and GitHub-hosted USAGE.md link
- Copy/Download buttons -- icon-only Clipboard and Download lucide buttons in the detail header
- Shared
lib/api.js--API_BASEderivation +apiGet/apiPost/ensureToken/prefetchTokenhelpers with bearer-token bootstrap and 401 retry - Shiki unification --
CodeBlock.svelterefactored to use Shiki viahighlightCode()fromlib/markdown.js; deleted the hardcoded keyword tokenizer, gaining proper grammar-based highlighting for 200+ languages across chat, artifact markdown, and artifact code - Subcomponent extraction --
ArtifactPanel.sveltesplit into orchestrator +ArtifactList,ArtifactDetailHeader,ArtifactDetailBody,RemoteUpdateBanner,ArtifactEditorfor clean ownership boundaries (R6-1)
POST /api/artifacts/{conv}/{name}-- edit-in-place endpoint with conditional registration, loopback-only binding, bearer-token auth, and participant registry authorizationGET /api/capabilities-- returnswritable+ feature flags for client feature detectionGET /api/web-token-- loopback-only bearer token fetch; regenerates on every daemon restart- New config keys --
web.api_base,web.allow_remote_edits,web.ws_url,web.csp_extra_connect_src,web.strict_cors,web.migrate_nfc_on_startup,web.use_legacy_codeblock_highlighter,web.markdown_render_enabled REVERSE_PROXYenv var -- reverse-proxy deployment flag; disables the POST edit route- Windows-filesystem-compatible artifact naming -- broader character set (spaces, most punctuation, Unicode), NFC normalization, reserved-name blocking, startup NFC migration with collision quarantine (R6-2)
- Version counter fix -- post-pruning correctness via
max(v.version)instead oflen()
- Bearer-token auth -- fresh on every daemon start, loopback-only fetch endpoint, per-request 401 retry, consistent
"Session expired"error copy - Loopback-only POST -- never trusts
X-Forwarded-For - Feature flag default-off --
allow_remote_edits: false; opt-in rollout - Content-Security-Policy headers --
default-src 'self', strictconnect-src,X-Frame-Options: DENY - CORS exact-match -- replaces the previous buggy substring match (was CVE-adjacent)
- Path-traversal defenses -- symlink realpath check, NFC normalization,
.json-suffix rejection, case-collision protection, fullwidth-char (U+FF00-U+FFEF) rejection - DOMPurify strict sanitize config + external-image placeholder interception
- axe-core scan as blocking CI gate -- 17 tests covering 6 panel states + reduced-motion variants
- 21 keyboard-navigation tests -- listbox ArrowUp/Down/Home/End/Enter/Space/Escape, focus management, Esc priority
- Non-color diff signaling --
+/-/=gutter glyphs - ARIA wiring --
aria-pressedon star button,aria-live="assertive"on remote-update banner, dynamicaria-labelthroughout prefers-reduced-motion-- disables banner slide animationnested-interactivefix -- star button inside a row button in ArtifactList, converted to non-nested structure
- 117 Vitest tests (new Vitest setup -- first JS tests in this repo) -- markdown XSS corpus (13 vectors), render race guard, diff chunked-fetch bounds, CodeBlock Shiki, starred-artifacts localStorage, store self-update TTL, detail view rendering, edit flow (autoresize, shortcuts, retry, dedup), axe a11y, keyboard a11y
- 203 Python tests -- 75+ new artifact-naming tests (30+ Unicode corpus), 37 server-plumbing tests (POST endpoint / token / CORS / conditional route registration / version counter)
- CI bundle-size check with hard ceilings:
index <= 180 KB gzipped,vendor-markdown <= 130 KB,vendor-diff <= 25 KB-- all passing with headroom - Rollback runbook -- 7 kill-switch config flags documented
- Fallback ladder -- documented in
CONTRIBUTING.md - Pinned exact dep versions (no caret ranges) -- prevents silent sanitizer drift from transitive updates:
marked@18.0.2,dompurify@3.4.1,shiki@3.0.0,marked-highlight@2.2.4,diff@8.0.1,vitest@4.1.5,@vitest/ui@4.1.5,jsdom@29.0.2,axe-core@4.11.3,@testing-library/svelte@5.3.1
plans/artifact-improvements.md(1862 lines) -- 6 adversarial review rounds, 45 findings all accepted, Svelte 5 conventions audit, Svelte MCP tooling mandate
Browse, create, and invite participants to conversations with full metadata, human-in-the-loop enforcement, and cross-client support.
ConversationMetaPydantic model for conversation metadata (topic, creator, created_at, member list)- Metadata file I/O -- atomic conversation creation using
O_CREAT | O_EXCLto prevent races - "general" channel bootstrap -- guaranteed on startup, undeletable
- Backfill migration -- auto-generates metadata for existing conversations missing metadata files
LastActivityTracker-- debounced writes for conversation last-activity timestamps
comms_conversation_create-- Create a conversation with topic, auto-joins creator + all human participants, posts system messages to both new conversation and #general (async)comms_conversation_update-- Update conversation topic with rate-limited system messages (async)comms_invite-- Invite a participant to a conversation, posts invite notification in #general (async)
comms_conversations-- Newallparameter: whenall=true, returns ALL conversations on the server (not just joined), including topic, member_count, message_count, last_activity, and joined statuscomms_join-- Now async. On implicit conversation creation (first join to a new name), triggers same side effects ascomms_conversation_create: auto-joins humans, creates metadata, posts system message
- All human-type participants are automatically joined to any new conversation (server-enforced)
- Creation notifications always posted to #general so humans always see them
- "general" channel bootstrapped on startup, undeletable
GET /api/conversations?all=true-- List all conversations with metadata (topic, members, activity, joined status)
- Slide-out conversation browser panel for browsing all conversations on the server
- Join button for unjoined conversations
- "Browse All" button in sidebar
- System messages (
sender.type === "system") rendered with distinct style (no avatar, centered, muted, smaller font)
/discovercommand -- List all conversations with topic, join status, and last activity- System message rendering -- System-type MQTT messages routed to
add_system_message()for distinct rendering
- 42 unit tests in
tests/test_conversation.pycovering model, storage, atomic creation, backfill, bootstrap, LastActivityTracker, tool functions, invite validation, rate limiting, conversation listing withallparam
Versioned shared documents for multi-agent collaboration. Participants can create, update, read, list, and delete artifacts within conversations, with optimistic concurrency control to prevent silent overwrites.
- Pydantic models (
Artifact,ArtifactVersion) for versioned document storage - Artifact types:
plan,doc,code - Atomic writes (tmp + rename) to prevent corruption on concurrent access
- Version pruning -- max 50 versions per artifact, oldest pruned automatically
- Chunked reading -- 50K character chunks with offset/limit pagination for large artifacts
- Name validation -- lowercase alphanumeric + hyphens slug format
comms_artifact_create-- Create a new artifact (async, publishes system message to chat)comms_artifact_update-- Update with new version, optionalbase_versionfor optimistic concurrency (async)comms_artifact_get-- Read content with chunked pagination (sync)comms_artifact_list-- List artifacts with summary metadata, no content (sync)comms_artifact_delete-- Delete artifact + all versions (async, publishes system message)
GET /api/artifacts/{conversation}-- List all artifacts in a conversationGET /api/artifacts/{conversation}/{name}?version=N-- Get artifact with optional version selection- CORS/OPTIONS handlers for both artifact endpoints
- Slide-out panel triggered from header button (FileText icon)
- List view showing all artifacts with type badges, version count, and author
- Detail view with version selector dropdown and content display
- Fetches from REST API, reactive to channel changes
/artifact list-- List all artifacts in current conversation/artifact view <name>-- View artifact content/artifact help-- Show command help
- One JSON file per artifact:
~/.claude-comms/artifacts/{conversation}/{name}.json - No content size limit on write; reads chunked at 50K chars default
- System messages auto-posted to chat on create/update/delete (sender type "system")
- System messages use reserved key
00000000with type "system", published as raw JSON bypassing Message model validation
- 42 unit tests in
tests/test_artifact.pycovering models, storage, CRUD, validation, version pruning, chunked reading, optimistic concurrency, and MCP tool integration - All tools validate: caller key registered, conversation ID valid, artifact name valid, caller is conversation member
- Optimistic concurrency:
base_versionparam on update prevents silent overwrites when multiple participants edit the same artifact - Collaboration protocol: draft -> discuss -> revise -> approve
- 902 Python tests (up from 818) -- 42 new artifact tests, 42 new conversation discovery tests, plus TUI +20, CLI +31, MCP server +20, and hundreds of gap/integration tests across all modules
- Security fixes -- XSS vulnerability patched in SearchPanel (unsanitized HTML injection); CORS lockdown applied to REST API endpoints
- Performance audit complete -- All 8 audit findings addressed: IntersectionObserver O(1) optimization,
$effecttimer cleanup, message cap (5000), reaction array spread replaced with O(1) self-assignment, 3-chunk Vite build split - CONTRIBUTING.md created -- Developer onboarding guide with setup, testing, architecture, and contribution workflow
- Coverage report -- 68% overall (core modules 95-100%); coverage report committed to repo
- 30 Svelte components documented -- JSDoc annotations on all public props, events, and exported functions
- Dead code cleanup -- mqtt-store-v2 and other unused modules moved to
_alt/directory - CI lint gate -- Lint job now properly blocks builds; 109 ruff errors fixed across
src/andtests/ - pyright 0 type errors -- Full type checking passes clean across the Python codebase
- README badges added -- Test count, coverage, pyright, ruff, and license badges
- Svelte 5 reactivity in class stores -- RESOLVED --
$derivedstate inside theMqttStoreclass was not triggering component re-renders (e.g., message list, participant list). Root cause: Svelte 5 runes ($state,$derived) must live in.svelte.jsfiles to be compiled as reactive. The store was in a plain.jsfile, so runes were treated as normal variables. Fix: renamedmqtt-store.jstomqtt-store.svelte.js, which enables the Svelte compiler to process runes correctly. A module-level alternative (mqtt-store-v2.svelte.js) also exists for future use if the class-based pattern needs revisiting.
- TCP-to-WebSocket message bridging -- amqtt does not bridge messages between its TCP (:1883) and WebSocket (:9001) listeners. Clients on different transports cannot see each other's messages. All clients should use the same transport (WS recommended for web+TUI interop).
- 109 ruff lint errors fixed -- All
ruff checkandruff formatissues resolved acrosssrc/andtests/. CI lint job now passes clean. - CI workflow lint job gates properly -- Lint job no longer set to
continue-on-error; it now fails the build on lint violations as intended. - Web UI host configurable for Docker -- Web server bind address now reads from
config.yaml(web.host), defaulting to0.0.0.0in Docker containers for proper container accessibility. - TUI typing indicators + LWT -- Typing indicator re-trigger bug fixed on send; Last Will and Testament publishes to global
system/participants/{key}topic for cross-channel offline detection. - 25 API endpoint tests -- New test coverage for REST API endpoints (message history, identity, participants).
- 818 Python tests passing (up from 714) -- 104 new tests across TUI, CLI, and MCP server modules.
- 174 total commits for the overnight build session.
- Presence REST API (
/api/participants/{channel}) -- New REST endpoint returns the participant list for a given channel, includingclient(web/tui/mcp) andstatus(online/offline) fields. Enables external tooling and health checks to query channel membership without an MQTT subscription. - Build optimization: 3-chunk split -- Vite
manualChunksconfig splits the JS bundle intovendor-mqtt(mqtt.js),vendor-ui(bits-ui + lucide-svelte), and the app chunk. Eliminates the 500KB chunk size warning from production builds. - Stale presence filtering -- Both TUI and Web UI now filter out stale/offline presence entries from the participant list, fixing phantom participants that accumulated from retained MQTT messages of disconnected clients.
- Cross-browser integration test diagnostics -- Documented TCP-to-WebSocket bridging gap: the MQTT broker exposes TCP (:1883) and WS (:9001) as separate listeners, but amqtt does not bridge messages between transport types. All clients using the same transport (e.g., all WS) see each other's messages; cross-transport requires protocol bridging not yet implemented.
- 746 Python tests (up from 647) -- 36 expanded gap tests across broker, log exporter, MCP tools, notification hook, and CLI modules, plus 32 new tests in the final session
- REST API for message history (
/api/messages/{conversation}) -- Web UI now persists messages on page refresh via new REST endpoints backed by the MQTT message store - Unified identity endpoint (
/api/identity) -- Single REST endpoint returns the daemon's configured identity (name, key, type, client), used by Web UI and TUI to display consistent identity info - MCP
comms_joinpublishes MQTT presence -- Joining a conversation now publishes a retained presence message tosystem/participants/{key}, making MCP-connected agents visible to TUI and Web UI clients - Client type display -- Participants show their client type in the UI: "Phil (web)", "Phil (tui)", "claude-orchestrator (mcp)". Client field included in presence messages for all connection types.
- Broker crash resilience -- Daemon handles amqtt broker crashes on WebSocket disconnect gracefully with a retry loop instead of terminating the entire process
- Connection banner auto-hide + dismiss -- Connection status banner auto-hides after successful connect; dismiss button added for manual close
- Mobile hamburger menu -- Responsive navigation menu for narrow viewports
- Emoji picker enlarged -- Picker sizing increased for better usability; reaction badges improved with better visibility
- Full code cleanup -- Removed unused imports and dead code from both Python source files and Svelte components; moved misplaced import to top of EmojiPicker script block
- 12 user story E2E tests (2 rounds) -- Comprehensive end-to-end user story coverage across Web UI flows
- ~981 total tests -- 746 Python tests + 235 Playwright E2E tests across 25 spec files
- Debug cleanup passes -- Removed debug seed messages and console.debug logging injected by agents
- 14,076 lines of source code / 8,768 lines of test code -- 98+ commits overnight across 121 files changed
- MCP server + Web UI now actually start -- The daemon's
claude-comms startcommand previously only printed "MCP server ready" and "Web UI available" as placeholder messages without launching either server. Now the daemon starts the MCP server (uvicorn + FastMCP on:9920) and the web UI static file server (Starlette on:9921) as async tasks alongside the broker. Graceful shutdown added for both servers. (Placeholder audit #1 and #2 -- CRITICAL fix) - UserProfileView component (
web/src/components/UserProfileView.svelte) -- Slide-out panel for viewing other participants' info (avatar, name, handle, role badge, type, key, status). "View Profile" on someone's ProfileCard now shows their profile instead of opening your own Settings. Own profile still opens SettingsPanel. Added to Escape priority chain. - 7 user story E2E tests (
web/e2e/user-stories.spec.js) -- End-to-end flows: new user first experience, team discussion with threads, channel management, message reactions/interactions, search/navigation, customization/settings, mobile user at 480px viewport. All 7 passing with 26 screenshots. - Placeholder audit completed -- 25 items identified across Python backend and Svelte frontend; high-priority items (#1 MCP server, #2 web UI, #4 forward, #6 search filters, #7 sidebar search, #8 settings name persist) now resolved
- Sidebar channel search -- Sidebar search input now filters starred and unstarred channels by case-insensitive name match using
$derivedreactive state (Placeholder audit #7) - Search panel filter tabs wired -- All/Messages/Files/Code/Links tabs now actively filter results: URL regex for links, triple-backtick detection for code, attachment markers for files. Clicking a tab re-runs the search immediately (Placeholder audit #6)
- ForwardPicker component (
web/src/components/ForwardPicker.svelte) -- Modal overlay listing all channels except current; forwards viastore.forwardMessage()with confirmation toast. Replaces the "Forwarding coming soon" clipboard stub (Placeholder audit #4) - Settings name persistence -- Display name changes in SettingsPanel now persist to localStorage, surviving page reloads (Placeholder audit #8)
- Component polish -- DateSeparator (gradient lines, ember glow, hover effect), ReadReceipt (animated staggered check marks, hover tooltip with reader names, theme support), LinkPreview (favicon via Google S2, image thumbnail, hover elevation, proper
<a>element) - Store improvements -- JSDoc on all public methods,
messageCountderived state,getChannelById()andgetParticipantByKey()helpers, improved MQTT error messages (ECONNREFUSED shows broker URL, WebSocket-specific errors, offline/reconnect messages) - Utils improvements --
formatRelativeTime()for human-readable timestamps,sanitizeHtml()for safe rendering,truncateText()with word-boundary awareness, improvedparseMentions()edge case handling - Notification sound toggle -- Web Audio API beep (880Hz sine, 300ms decay) gated by
soundEnabledstate,toggleSound()export, click-to-focus on notification click, channel name prefix in notification body - Profile card buttons functional -- "Message" button pre-fills
@namein input; "View Profile" opens UserProfileView for other users or SettingsPanel for self
- TUI phantom participants -- TUI participant list showed stale/offline entries from retained MQTT presence. Now filters the same way as the Web UI.
- Presence API missing fields --
/api/participants/{channel}initially returned only name/key; addedclientandstatusfields for full participant metadata.
- MCP presence not published on join --
comms_jointool did not publish an MQTT retained presence message, making MCP-connected agents invisible to TUI/Web UI participant lists. Now publishes tosystem/participants/{key}. - Broker crash on WS disconnect -- amqtt broker could crash the entire daemon process when a WebSocket client disconnected ungracefully. Added exception handling to keep the daemon running.
- Svelte 5 reactivity for historical messages -- Deferred
fetchHistorystate update so Svelte 5 reactivity correctly renders messages loaded from the REST API on page refresh. - Svelte cleanup -- Removed unused imports and dead code; fixed misplaced import in EmojiPicker component.
- Debug artifacts removed -- Cleaned up debug seed messages and
console.debuglogging left by overnight agents. - Own presence overwrite -- Skip own presence messages to prevent offline status from overwriting online status during reconnection.
- View Profile showed own settings -- Clicking "View Profile" on any user's ProfileCard always opened the SettingsPanel. Now correctly shows the target user's profile via UserProfileView.
- ForwardPicker Svelte 5 syntax -- Used Svelte 4 event modifier syntax (
onmousedown|stopPropagation) which broke the build. Fixed to Svelte 5 pattern.
- 504 Python tests (up from 360) -- 113 new MQTT integration tests across 5 rounds: broker lifecycle (31), MCP tools logic (43), log exporter (24), CLI commands (19), gap coverage for error handling and edge cases (27)
- 19 new CLI tests (
tests/test_cli.py) -- init config creation, name options, key generation, force overwrite, status commands, env var overrides, deep merge - 43 new TUI tests (
tests/test_tui.py) using Textual'sapp.run_test()withPilot-- app rendering, channel switching, message sending, keyboard shortcuts (Ctrl+Q/N/K), edge cases (long messages, unicode, code blocks, @mention tab completion, unread badges, presence updates) - 60 new comprehensive web E2E tests (
web/e2e/overnight-comprehensive.spec.js) -- 9 rounds covering sidebar, header, input, messages, panels, modals, member list, theme/responsive, keyboard - 19 new members/theme/responsive tests (
web/e2e/overnight-members-theme.spec.js) -- member list, profile card (7 tests), theme toggle (3 tests), responsive layout at 5 viewpoints (5 tests) - 10 new accessibility Playwright tests (
web/e2e/a11y-keyboard.spec.js) -- Tab focus movement, focus-visible rings, Enter activation, Escape handling, ARIA roles verification, sr-only class verification
- ARIA roles added to 21 components --
role="log"on ChatView,role="article"on MessageBubble,role="toolbar"on MessageActions,role="search"on SearchPanel,role="complementary"on PinnedPanel/ThreadPanel/SettingsPanel,role="status"/role="alert"on ConnectionStatus,role="alert"on NotificationToast,role="separator"on DateSeparator,role="presentation"on backdrops,aria-modal/aria-labelon dialogs - All 7 svelte-ignore a11y suppressions removed -- replaced with proper semantic roles and keyboard handlers (FileAttachment, MessageBubble, PinnedPanel, EmojiPicker, ProfileCard x2, SearchPanel)
.sr-onlyutility class added toapp.cssfor screen reader labels on emoji search, search input, and thread reply inputaria-hidden="true"on decorative elements: DateSeparator SVGs, ReadReceipt SVGs, ConnectionStatus dots, ReactionBar emoji spansaria-labelon all icon-only buttons -- ThemeToggle, ScrollToBottom, CodeBlock copy, ReactionBar buttons with emoji name + count, Avatar with profile namearia-pressedstate on ReactionBar toggle buttons- Enhanced
focus-visibleCSS rules -- 2px outline + box-shadow on buttons, inputs, textareas, and ARIA interactive roles
- 12 sender colors -- expanded from 8 to 12 (ember, gold, teal, rose, emerald, sky, violet, pink, bright amber, light blue, purple, green)
- Sender type icons -- robot emoji for Claude, person emoji for human
- Channel message previews -- last message preview under each channel name (sender: text, truncated to 22 chars) with
set_channel_preview()API - Muted channel indicator -- bell-off emoji with
--mutedCSS class andset_channel_muted()API - Unread badges -- inline count display in channel header row
- New
StatusBarwidget (tui/status_bar.py) -- connection state (green/red dot), active channel with#prefix, participant count, typing indicators ("pencil username is typing..." in amber italic), current user identity - @mention highlighting in amber/gold throughout message text
- Warmer Carbon Ember styling -- ember-tinted borders (
#2a2017), updated sidebar backgrounds (#1a1a1c), themed Footer key hints, 1px scrollbar
- Improved empty states -- ChatView shows MessageSquare icon with pulsing double-ring animation; SearchPanel shows contextual empty states for before-search and no-results
- Improved connection states -- animated bouncing dots during connecting, retry countdown with RefreshCw icon during disconnect, spinning reconnect indicator
- Tooltips added to SearchPanel close/filters, NotificationToast dismiss, FileAttachment download, DateSeparator (full date), LinkPreview, ReadReceipt
- ScrollToBottom entrance animation -- spring slide-up, hover bounce, badge animation
- Toast progress bar -- amber gradient countdown bar for auto-dismiss timing
- CodeBlock theme-independence -- visual regression fix ensuring code blocks render correctly across themes
- ContextMenu -> bits-ui ContextMenu --
ContextMenu.Root/Content/Item/Separatorwith controlled open state, Floating UI viewport-aware positioning (replaces manual clamping), arrow key navigation between items, Enter/Space activation, Escape/click-outside dismiss via bits-ui layers,data-highlightedkeyboard focus state - ChannelModal -> bits-ui Dialog --
Dialog.Root/Portal/Overlay/Content/Title/Closewith focus trap,role="dialog",aria-labelledbyauto-wiring, portal rendering into document body, Escape and backdrop dismiss handled natively - EmojiPicker -> bits-ui Popover --
Popover.Root/Contentwith click-outside dismiss,onOpenAutoFocusfor search input auto-focus, controlledopenprop for parent state management - ProfileCard -> bits-ui Popover --
Popover.Root/Contentreplacing manual backdrop div, click-outside and Escape dismiss via bits-ui layers, removed a11y suppression comments - MentionDropdown -> bits-ui Combobox --
Combobox.Root/Input/ContentStatic/Itemprovidingrole="listbox"/role="option",aria-selected,aria-activedescendant,data-highlighted, hidden input for keyboard event ownership (replaces manual keydown handler and index tracking)
- ~37 inline SVGs replaced with lucide-svelte imports across 15 components -- tree-shakeable, consistent sizing via
sizeprop - App.svelte:
Users,Search,Pin,Settings - ThemeToggle.svelte:
Sun,Moon - Sidebar.svelte:
Hash,VolumeX,Plus,Settings - MemberList.svelte:
Search - MessageInput.svelte:
Type,Code,Paperclip,Smile,SendHorizontal - MessageActions.svelte:
Reply,Smile,Ellipsis - ScrollToBottom.svelte:
ChevronDown - ContextMenu.svelte:
Reply,Forward,Pin,Copy,Smile,MailOpen,Trash2 - ProfileCard.svelte:
Star - PinnedPanel.svelte:
Pin,X - SearchPanel.svelte:
X - ThreadPanel.svelte:
MessageSquare,Send,X - CodeBlock.svelte:
Copy,Check - FileAttachment.svelte:
File,Download
- SettingsPanel (new component) -- slide-out panel with profile editing, notification toggles, appearance section, connection status; wired to header settings button and Sidebar gear button
- Member list toggle -- header member count pill toggles
MemberListvisibility viashowMemberListstate - Member search -- search input in MemberList filters online/offline members by name with
$derivedreactive filtering - Attach file button -- hidden file input triggered by attach button; shows "File sharing coming soon" notice
- Format help button -- toggles Markdown formatting reference popover (
**bold** *italic* `code`) - Code snippet insertion -- inserts fenced code block template at cursor position in message input
- Context menu Forward action -- copies message body to clipboard with toast notification
- Context menu Mark Unread action -- calls
store.markUnread(message)settingunreadFromcursor - Context menu Delete action -- opens ConfirmDialog (new bits-ui Dialog component) for confirmation, then calls
store.deleteMessage(messageId) - ConfirmDialog (new component) -- reusable confirmation dialog using bits-ui Dialog, with danger styling option
- Channel mute toggle -- mute buttons on sidebar channels call
store.muteChannel(channelId), visual.mutedclass with reduced opacity, VolumeX icon indicator - File download handler -- FileAttachment download button triggers programmatic
<a>download withurlprop - More button -> context menu -- MessageActions More button opens context menu at button position via
onMore->onContextMenu - Store methods added:
markUnread(),deleteMessage(),muteChannel(),forwardMessage()inmqtt-store.svelte.js
- bits-ui -- headless Svelte 5 UI primitives (ContextMenu, Dialog, Popover, Combobox) for accessibility, keyboard nav, ARIA roles, focus trapping, floating positioning
- lucide-svelte -- tree-shakeable SVG icon library (1500+ icons as Svelte components)
- 10 parallel testing agents deployed for functional browser testing across the entire web UI -- 121+ Playwright tests written, 12 bugs found and fixed
- 20 Playwright E2E spec files (
web/e2e/) covering: messages (10 tests), emoji picker (10), channel switching (7), console smoke test (18 interactions), app loading (5), sidebar (8), chat (6), panels (11), modals (7), member list (6+11), context menu (5), console errors (3), channel modal flow (11), keyboard shortcuts (10), theme/responsive (7), user stories (7) - 120+ test screenshots captured across all testing areas (
mockups/test-*.png,mockups/screenshot-*.png) - 668+ total tests across the project: 504 Python + 43 TUI + 121+ Playwright browser E2E
- Zero JS runtime errors confirmed across all 18 interaction types during comprehensive smoke testing
playwright.config.js-- Headless Chromium, screenshots on failure, video on failure, 1 retry, 30s timeout, built-in web server config, CDP workaround for mqtt.js event loop blocking- npm test scripts --
test(headless),test:ui(Playwright UI mode),test:headed(visible browser) data-testidattributes -- 60+ attributes added across all 18 interactive Svelte components for reliable Playwright test selectors (replaces fragile CSS class selectors). All E2E test files use[data-testid="..."]selectors.
addReaction()method inmqtt-store.svelte.js-- creates/toggles reactions on messages with proper count tracking and active stateonReactprop threading throughMessageBubble->MessageGroup->ChatView->App.sveltefor full reaction callback chain- React button wired in
MessageActions.svelte-- previously had noonclickhandler handleEmojiSelectinApp.sveltenow callsstore.addReaction()instead of being a TODO
- 6 Python modules hardened --
participant.pyandmessage.pyvalidators acceptNonesafely;log_exporter.pyhandles missing/malformed fields gracefully;hook_installer.pyvalidates inputs and wraps file I/O in try/except;broker.pyvalidatesgenerate_client_id()inputs;mcp_server.pyreplaces bareassertwith properRuntimeError(asserts are stripped withpython -O)
- TUI Ctrl+K binding conflict -- Textual's built-in
Inputwidget bindsctrl+ktodelete_right_all, intercepting the app-level Ctrl+K conversation switching shortcut. Fixed by addingpriority=Trueto the binding inapp.py. - Unused CSS selector --
.header-members svgin App.svelte was unused because Svelte scopes CSS; changed to.header-members :global(svg)to pierce scoping.
addReactionmethod missing -- Store had no method to add/toggle reactions on messages; emoji picker selection was a TODO that never persisted reactions- React button had no onclick handler --
MessageActions.svelte"React" button did nothing when clicked - localStorage key not persisted -- Each browser session generated a new random key via
generateKey(), creating phantom participants that accumulated as retained MQTT presence messages. Fixed by persisting user key in localStorage. - Ctrl+K shortcut missing -- No keyboard shortcut existed to open the search panel. Added global
handleGlobalKeydownwithsvelte:window onkeydownbinding for Ctrl+K / Cmd+K toggle. - Escape key priority ordering -- Multiple components independently listened for Escape, causing all open panels to close simultaneously. Centralized Escape handling in
App.sveltewith priority order: modal > context menu > emoji picker > profile card > pinned panel > search panel > thread panel. - Focus return after closing panels -- After pressing Escape to close a panel, focus was lost to
document.body. Now returns focus to the message input after any Escape-triggered close. - ThemeToggle not wired --
ThemeToggle.sveltecomponent existed but was never imported or rendered inApp.svelte. Wired into the chat header actions bar. - No light theme CSS -- Only dark theme variables existed. Added
:root[data-theme="light"]rule block with full light color palette and converted hardcoded backgrounds in Sidebar, MemberList, and chat header to CSS variables. - Mobile viewport overflow -- At 320px and 480px viewports, content overflowed the screen width. Fixed sidebar to
display: noneat 480px, hid header elements, reduced padding, addedoverflow: hiddenon layout containers. - Context menu viewport edge clamping -- Context menu positioned at raw cursor coordinates without boundary clamping, rendering off-screen near viewport edges. Added
$derivedclamped coordinates constraining the menu within 8px of all edges. - Search panel z-index / close button unclickable -- Search panel (
z-index: 50) sat behind the chat header (z-index: 101), making its close button unreachable. Repositioned panel below the header. - Search input not auto-focused -- Opening the search panel did not focus the input field. Added
onMounthandler withbind:thisto auto-focus. - Header glow pseudo-element intercepting clicks --
.chat-header::afterglow effect could intercept pointer events. Addedpointer-events: none. - Duplicate channels in sidebar -- Starred channels appeared in both "Starred" and "Conversations" sections because conversations list rendered all channels instead of filtering out starred ones. Added
$derivedunstarredChannelsfilter. - Messages don't appear without MQTT broker --
sendMessage()only published to MQTT with no local echo. Added immediate local store update via#handleChatMessage()with deduplication, and removed the broker-required guard so the UI works offline. - Toast notifications never auto-dismiss --
addToast/dismissToastused in-place array mutations (push/splice) on$statearrays insidesetTimeoutclosures, which didn't reliably trigger Svelte 5 reactivity. Switched to immutable updates ([...arr]/filter()).
- Unicode rendering -- Fixed 4 Svelte template escape sequences rendering as literal
\uXXXXtext:\u2318K->⌘K,\u2605->★,\u25BE->▾(2 instances). Replaced 25 surrogate pair unicode escapes in EmojiPicker with actual emoji characters. - Message bubble shadows -- Adjusted to match R10 mockup (simplified shadow, tweaked human inset glow)
- Consecutive bubble corners -- Added rounded corner treatment for grouped messages
- Chat header -- Fixed letter-spacing, added border-bottom from spec, matched background gradient
- Input area -- Changed to gradient background with proper border-top per mockup
- Scanline overlay removed --
.center::afterrepeating-linear-gradient not in design spec - Mask fade reduced -- Messages container mask-image fade from 20px to 8px to avoid obscuring content
- Connection status banner -- Reduced prominence when connected (lower opacity, smaller padding/font)
- Empty state -- Added "No messages yet" placeholder for channels with no messages
- Mobile responsive -- Member list hidden on narrow viewports; sidebar overlays content below 480px
- mqtt.js blocks browser event loop during WebSocket reconnection cycles (~3s interval), causing Playwright's
page.click(),page.fill(), andpage.evaluate()to hang indefinitely - Workaround documented: (1) WebSocket mock via
addInitScriptprevents MQTT from connecting, (2) CDPRuntime.evaluatebypasses Playwright's actionability wait system - Browser crashes under memory pressure -- Chromium renderer processes killed after ~3s in WSL2 with low RAM when MQTT reconnection + Svelte rendering + CSS animations combine; all existing tests complete within the window
Dockerfile-- Multi-stage build: Stage 1 (node:22-slim) compiles the Svelte web UI, Stage 2 (python:3.12-slim) installs the Python package. Exposes ports 1883 (MQTT TCP), 9001 (MQTT WS), 9920 (MCP), 9921 (Web UI). Health check probes MQTT broker every 30s.docker-compose.yml-- Single-service deployment with named volumecomms-datafor persistent config/logs,CLAUDE_COMMS_PASSWORDenv var,restart: unless-stoppedpolicy..github/workflows/ci.yml-- GitHub Actions CI pipeline:- Lint job:
ruff check+ruff format --checkonsrc/andtests/ - Test job: Matrix across Python 3.10, 3.11, 3.12 with pip caching; uploads JUnit XML results as artifacts (30-day retention)
- Build Web job: Node 22 with npm caching; builds Svelte app; uploads dist as artifact
- Concurrency control: cancels in-progress runs for same git ref
- Lint job:
- Integration test suite (
tests/test_integration.py) -- 45 tests covering cross-module interactions: config init flow, message roundtrip, mention resolution pipeline, log exporter integration, shared deduplicator, participant registry, hook installer, and MCP tools pipeline - E2E test suite (
tests/test_e2e.py) -- 22 tests covering full system flows with aMockBrokersimulating MQTT pub/sub: two-participant chat, targeted messaging, conversation lifecycle, presence flow, name changes, log format verification, JSONL replay, notifications, and a complete end-to-end session
- TUI Client (
src/claude_comms/tui/) -- Textual-based terminal chat with 3-column layout (channels, chat, participants), direct MQTT connection via aiomqtt@work()async worker, per-conversation message storage, deterministic sender colors (MD5 hash to Carbon Ember palette), triple-backtick code block rendering (Rich Syntax, Monokai), @mention Tab completion, unread badges, presence indicators, modal new-conversation dialog - Svelte 5 Web Client (
web/) -- 35-file Svelte 5 + Vite SPA implementing the "Obsidian Forge" design language. Svelte 5 runes ($state,$derived,$effect), Tailwind CSS v4 with@themedirective, mqtt.js direct WebSocket connection to broker. Components include: channel sidebar, message bubbles with grouping, @mention autocomplete, emoji picker, thread panel, search panel, context menu, profile cards, notification toasts, scroll-to-bottom button, file attachments, link previews, read receipts, reaction bars, date separators. All mockup animations replicated (ambient drift, brand breath, badge pulse, typing wave, send shine, etc.)
-
MQTT topic routing --
#handleMessageused fragileparts[2] === 'conv' || parts[1] === 'conv'condition which missedsystem/participants/+topics entirely (participant registry never populated). Replaced with proper prefix-strip and directtopicParts[0]matching. -
MQTT typing channel extraction --
#handleTypingusedthis.activeChannelinstead of extracting the channel from the MQTT topic, causing typing indicators to always appear in the viewer's active channel instead of the actual typing channel. -
MQTT LWT topic -- Last Will and Testament published to a channel-specific presence topic (
conv/{channel}/presence/{key}), which only notified that single channel. Changed tosystem/participants/{key}so offline status is visible globally. -
Sidebar onShowProfile event mismatch -- Sidebar passed raw participant object to
onShowProfilewhile App.svelte's handler expectede.detailwrapper. Normalized all event callback props to pass data directly (no{ detail: ... }wrapper) across Sidebar, MemberList, MessageBubble, ContextMenu, and EmojiPicker. -
A11y: clickable divs/spans without keyboard handlers -- Added
onkeydownhandlers (Enter/Space) to all interactivediv/spanelements: channel items (Sidebar), member items (MemberList), sender name and thread indicator (MessageBubble), user avatar (Sidebar). -
A11y: non-semantic clickable elements -- Converted header-members from
divtobutton(App.svelte), section collapse arrows fromspantobutton(Sidebar), search filter pills fromspantobutton(SearchPanel). -
A11y: labels without associated controls -- Added
for/idassociations to Channel Name and Description labels in ChannelModal. -
A11y: icon-only button without label -- Added
aria-label="Send reply"to ThreadPanel send button. -
A11y: noninteractive tabindex on Avatar -- Split Avatar into two branches (clickable with role/tabindex/onkeydown vs. static without) to eliminate the noninteractive tabindex warning.
-
Dependency conflict resolved -- Changed
mcp[cli]tomcp(without the[cli]extra) and pinnedtyper>=0.15.0,<0.16.0inpyproject.toml. The[cli]extra requiredtyper>=0.16.0which conflicted withamqtt's pin ontyper==0.15.4.
- Obsidian Forge design finalized -- Evolved from "Phantom Ember" through 17 iterative adversarial refinement rounds across 11 initial concepts. Final design language: dark as polished obsidian, warm as ember glow, alive with subtle breath. Applied to both TUI (Carbon Ember palette) and Web UI (full Obsidian Forge).
0.1.0 -- 2026-03-29
Initial release. Built across three development batches by 8 parallel Claude Code agents.
pyproject.toml-- Hatchling build system, all dependencies (amqtt,aiomqtt,mcp,typer>=0.15.0,<0.16.0,pyyaml,rich,pydantic), optional extras (tui,web,all,dev), entry pointclaude-comms, pytest configsrc/claude_comms/__init__.py-- Package init with__version__ = "0.1.0"src/claude_comms/__main__.py--python -m claude_commsentry point
- YAML config management at
~/.claude-comms/config.yaml load_config()with deep merge against defaults for forward compatibilitysave_config()with automaticchmod 600enforcementget_default_config()withsecrets.token_hex(4)identity key generation- Password resolution chain:
CLAUDE_COMMS_PASSWORDenv var > YAML value > warning - WSL2 chmod fallback with warning when file permissions cannot be set
- Pydantic v2
Messagemodel withSenderembedded model - Fields:
id(UUID4),ts(ISO 8601 with timezone),sender,recipients,body,reply_to,conv Message.create()convenience constructor with auto-generated ID and timestampto_mqtt_payload()/from_mqtt_payload()JSON serialization (string and bytes)topicproperty generatingclaude-comms/conv/{conv}/messagesis_broadcast/is_for(key)routing helpers- Conversation ID validation:
^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$regex, reserved names (system,meta) - Recipient key validation: 8 lowercase hex characters
- Pydantic v2
Participantmodel withkey,name,typefields - Key generation via
secrets.token_hex(4)(8 lowercase hex chars, ~4 billion combinations) Participant.create()with auto-generated keywith_name()for immutable name changes (key preserved)- MQTT serialization and
registry_topicproperty
extract_mentions(body)-- pulls display names from[@name1, @name2]prefixstrip_mentions(body)-- removes prefix, returns bare textbuild_mention_prefix(names)-- constructs[@name1, @name2]prefixresolve_mentions(body, name_to_key)-- resolves names to participant keys with deduplication
EmbeddedBroker-- lifecycle wrapper aroundamqtt.broker.Brokerfrom_config()factory,start()/stop()async lifecycle, PID file management- TCP (
:1883) and WebSocket (:9001) listeners - Config-driven auth, JSONL log replay on startup
MessageDeduplicator-- bounded LRU seen-ID set (default 10,000 entries), thread-safeMessageStore-- in-memory per-conversation message history (configurable cap, default 1,000)replay_jsonl_logs()-- reconstructs message history from.jsonlfiles on startupgenerate_client_id()-- unique MQTT client IDs:claude-comms-{component}-{key}-{random}
LogExporterclass withwrite_message()andwrite_presence()methods- Per-conversation
.log(human-readable) and.jsonl(structured) files - UUID-based deduplication via shared
MessageDeduplicator - Conversation ID validation (prevents path traversal)
- Automatic header generation on first write
- Per-conversation
format_log_entry(msg)-- renders timestamped message blocks with 4-space body indentformat_log_header(conv_id, ts)-- renders====separator headerformat_presence_event(name, key, event, ts)-- renders--- name (key) joined/left ---lines- Log rotation -- numbered suffix rotation (
.1,.2, ...) at configurable size - Format modes:
"text","jsonl","both"(default)
- FastMCP server with
stateless_http=Trueandjson_response=True - Binds to
127.0.0.1:9920(localhost security boundary) - MQTT subscriber background task with auto-reconnect (2s backoff)
- Separate persistent publish client for outbound messages
- JSONL log replay into
MessageStoreon startup
ParticipantRegistry-- thread-safe in-memory participant tracking with per-conversation membership, name-to-key index, read cursors, mixed name/key recipient resolution- 9 tool implementations:
comms_join-- join conversation, name-based idempotencycomms_leave-- leave conversationcomms_send-- send message with name-to-key resolution and @mention prefixcomms_read-- read messages with count/since pagination + token-aware truncationcomms_check-- unread counts across conversationscomms_members-- list conversation participantscomms_conversations-- list joined conversations with unread countscomms_update_name-- change display name (key preserved)comms_history-- search message history by text/sender with token truncation
- Token-aware pagination: ~4 chars/token, 80,000 char cap (~20k tokens)
init-- generate identity key, create config, set permissionsstart-- start daemon (broker + MCP), foreground or--background, optional--webstop-- SIGTERM with 10s grace period, SIGKILL escalation, stale PID cleanupsend-- publish message via short-lived aiomqtt client,-cconversation,-trecipientstatus-- daemon PID, broker config, MCP endpoint, web UI, identity, broker connectivity probetui-- launch Textual TUIweb-- open browser to web UI URLlog--tail -fwith Python polling fallbackconv list-- discover from log files + configconv create-- validate conv_id, publish retained metadata to brokerconv delete-- confirmation prompt (skip with--force), clear retained metadata
install_hook()-- generates platform-appropriate script (bash/cmd), installs to~/.claude/hooks/, updates~/.claude/settings.jsonwith PostToolUse entryuninstall_hook()-- removes script file and settings.json entry- Unix script: drain stdin, check notification file, read + truncate atomically, format messages, output JSON with
additionalContext - Windows script: same flow using
more > nuland PowerShell - Idempotent (replaces existing entries, no duplicates), 5-second timeout
app.py-- 3-column layout (channels | chat | participants), MQTT via aiomqtt@work(), keybindings (Ctrl+Q quit, Ctrl+N new conversation, Ctrl+K cycle), modal new-conversation dialogchat_view.py-- Rich Panels with deterministic sender colors (MD5 hash -> Carbon Ember palette), code block highlighting (Monokai), per-conversation message storage, client-side dedupchannel_list.py-- conversation sidebar with active highlight and amber unread badgesparticipant_list.py-- presence indicators: green (online), amber (away), gray (offline)message_input.py-- @mention Tab completion cycling through matching participant namesstyles.tcss-- Carbon Ember themed Textual CSS
- Svelte 5 + Vite project scaffolding
- "Obsidian Forge" design language
- 11 initial design concepts (A through K): Discord, Modern Light, Cyberpunk, Midnight Amethyst, Carbon Ember, Deep Ocean, Obsidian Rose, Phantom Jade, Obsidian Ember, Phantom Ember, Ember Fusion
- 17 refinement rounds on Concept J (Phantom Ember -> Obsidian Forge)
- Final interactive mockup:
concept-j-phantom-ember-v2-r10-interactive.html
tests/conftest.py-- shared fixtures (registry, store, publish_spy, tmp_config)tests/test_config.py(21 tests) -- config path, identity key, save/load, permissions, deep merge, password resolutiontests/test_message.py(33 tests) -- creation, JSON round-trip, validation, routingtests/test_mention.py(21 tests) -- extract, strip, build, resolve with edge casestests/test_participant.py(26+ tests) -- key generation, validation, model, serializationtests/test_broker.py(50+ tests) -- deduplicator, store, JSONL replay, broker lifecycletests/test_log_exporter.py(46 tests) -- formatting, rotation, dedup, conv validationtests/test_mcp_tools.py(42 tests) -- all 9 tools, registry, token paginationtests/test_notification_hook.py(45 tests) -- script generation, settings manipulation, install/uninstalltests/test_integration.py(45 tests) -- cross-module integration: config flow, message roundtrip, mention pipeline, log exporter, dedup, registry, hook installer, MCP tools pipelinetests/test_e2e.py(22 tests) -- end-to-end flows with MockBroker: two-participant chat, targeted messaging, conversation lifecycle, presence, name changes, JSONL replay, notifications
web/e2e/messages.spec.js(10 tests) -- type, send, grouping, wrapping, @mentions, empty guard, alignment, timestamps, auto-scrollweb/e2e/emoji-picker.spec.js(10 tests) -- open/close, emoji selection, reactions, category tabs, search, frequent emojisweb/e2e/smoke-test-all-interactions.spec.js(18 interactions) -- comprehensive console error monitoring across all UI interactionsweb/e2e/test-members.spec.js(11 tests) -- avatars, presence dots, profile card positioning/closing, role badges, mobile hidingweb/e2e/sidebar.spec.js(8 tests) -- channel list, active highlight, collapse/expand, new conversation, search, user profileweb/e2e/modals.spec.js(7 tests) -- channel modal lifecycle, form fields, cancel, backdrop/Escape close, toggleweb/e2e/chat.spec.js(6 tests) -- input, Enter/button send, message container, bubble display, hover actionsweb/e2e/panels.spec.js(6 tests) -- search/pinned panel open/close, toggle behavior, channel switching with panelweb/e2e/member-list.spec.js(6 tests) -- sidebar visible, header count, sections, profile card open/contents/closeweb/e2e/app-loads.spec.js(5 tests) -- page load, 3-column layout, header, input, no console errorsweb/e2e/context-menu.spec.js(5 tests) -- right-click menu, items, close behaviorsweb/e2e/console-errors.spec.js(3 tests) -- navigate all interactions without JS errors, rapid operationsweb/e2e/channel-modal-flow.spec.js(11 tests) -- open modal, form fields, type name/description, private toggle, cancel, backdrop close, Escape close, create channel, active state, empty name validationweb/e2e/keyboard.spec.js(10 tests) -- Ctrl+K opens search, Escape priority ordering, focus return to input, Enter/Shift+Enter, Tab navigation, focus rings, Ctrl+K while typingweb/e2e/theme-responsive.spec.js(7 tests) -- dark/light theme toggle, 5 viewport sizes (1920-320px), resize transitions, no mobile overflowweb/e2e/user-stories.spec.js(7 tests) -- E2E user stories: new user first experience, team discussion with threads, channel management, reactions/interactions, search/navigation, customization/settings, mobile user
- MQTT 3.1.1 via amqtt -- embedded, no external broker required
- Stateless MCP via FastMCP with
stateless_http=True-- each request independent, multiple Claude Code instances share one server - Server-side deduplication as primary defense against QoS 1 at-least-once redelivery
- Localhost security boundary for MCP server (no auth layer)
- LWT (Last Will and Testament) for automatic offline detection
- Retained messages for presence state persistence
- Token-aware pagination to stay within MCP 25,000-token output limit
- Config forward compatibility via deep merge with defaults
- Lazy imports throughout CLI for fast startup and graceful degradation
- 11 initial UI concepts explored across different aesthetic directions
- Concept J (Phantom Ember) selected as winner
- 17 iterative adversarial refinement rounds producing "Obsidian Forge" final design
- Architecture plan survived 7 adversarial review rounds before APPROVED status
- 64 source files across Python, Svelte, JS, CSS, and shell scripts
- 668+ total tests: 504 Python (12 test modules, ~0.5s) + 43 TUI (Textual run_test) + 121+ Playwright browser E2E (20 spec files) with 120+ test screenshots
- 10 parallel testing agents deployed for comprehensive functional browser testing, finding and fixing 12 bugs
- Zero JS runtime errors confirmed across all interaction types
- 27 Svelte components (26 in
components/+App.svelte) with 60+data-testidattributes - 18 Python source files (14 modules + TUI subpackage)
- 63 agent work logs documenting all development and testing activity
- 4 deployment targets: pip install, Docker, docker-compose, VPS
- WSL2 with Windows-mounted filesystems may not support
chmod 600on config files (falls back to warning) - Architecture plan example key
phil0e8acontains non-hex characters -- all real keys use[0-9a-f]{8}only - mqtt.js event loop blocking -- The mqtt.js library blocks the browser event loop during WebSocket reconnection, which affects Playwright testing (workaround: WebSocket mock + CDP
Runtime.evaluate). Does not affect normal user interaction. - Retained presence accumulation -- Previously, each browser session generated a new unique key. Now fixed via localStorage persistence (key survives reloads), but old phantom retained messages from prior sessions are not cleaned up. TTL-based cleanup still recommended for long-running deployments.