Skip to content

Release SwarmBar 0.1.5 - #2

Merged
umzcio merged 19 commits into
mainfrom
release/v0.1.5
Sep 7, 2026
Merged

umzcio merged 19 commits into
mainfrom
release/v0.1.5

Conversation

@umzcio

@umzcio umzcio commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Release SwarmBar 0.1.5 (build 6) with notification fixes and the initial read-only SSH discovery for remote Claude Code sessions.

Notifications now recognize Codex final questions, deduplicate identified requests across transcript metadata writes and delayed polls, and fire after session sync applies hook overrides and dismissals. Stop hooks no longer invent questions. Providers without event IDs retain their existing timestamp behavior.

Remote discovery adds host labels and reachability reporting, keeps remote rows read-only, and handles SSH disconnects and helper failures. Includes the signed Sparkle appcast for the release DMG.

Validation: all 330 tests pass locally, including 19 new notification regressions. The release pipeline verifies Developer ID signing, notarization, stapling, and Gatekeeper assessment for the app and DMG.

umzcio and others added 19 commits August 16, 2026 18:54
…tails

Review findings on the remote helper:
- claude_roots() was walked twice per snapshot (once inside
  claude_sessions, once again to fill the payload's roots field),
  doubling privileged directory listings on a command polled every 10s
  and silently dropping any warning from the discarded second pass.
  claude_sessions now takes roots as a parameter instead of re-deriving
  them.
- main() had no exception guard around command dispatch, so any
  exception snapshot() didn't already defend against would kill the
  process mid-stream with no response line, breaking the
  one-JSON-object-per-line contract for the rest of the ssh session.
  Wrapped dispatch in try/except, returning a short {"ok": false,
  "error": ...} line with no traceback.
- tail_of()'s docstring promised "trailing complete lines" but a
  mid-write file (the normal state of an active session) returned a
  truncated fragment as its last line. It now trims a dangling
  incomplete final line so what's shipped is always whole records.

Wire format and the three hazard guards (glob-inside-sudo avoidance,
processes_failed disambiguation, backup-root exclusion) are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
noLivenessWhenPsFailed now ships a non-empty processes array with a
matching cwd alongside processes_failed:true, so only the
processesFailed gate (not the wire protocol's own empty-array
invariant) can keep processAlive false. decodesFixture now asserts
the fixture's exact session count, and that sessions(from:) returns
the same count, so a future parser regression that silently drops a
row fails loudly.
snapshot() now guards against actor reentrancy: a second call while one
is in flight throws .busy instead of racing two detached reads of the
same pipe. snapshot() also owns its own failure bookkeeping, routing
any thrown error through noteFailure before rethrowing, so reachability
cannot lag a caller that forgets to report a failure separately.

Also clamps backoffSeconds' negative input (1 << -1 == 0 would violate
the 1-second floor) and adds a boundary test at the 60-second cap.
Reinstalling on every failed poll doubled ssh connection attempts while a
host stayed down: installHelper spawned its own connection and the
following snapshot retry spawned a second one, each with its own
ConnectTimeout. Only reinstall once backoff has saturated at the 60 second
cap, where "the helper is broken" becomes a plausible explanation and a
reinstall costs one extra connection per minute instead of one per cycle.

Extracted the decision into a pure, nonisolated shouldReinstall(afterBackoff:)
so it is testable, and covered it below, at, and above the cap.
The container-level .accessibilityLabel collapsed CompactSessionRow's
outer HStack into one opaque VoiceOver element, taking the
Approve/Deny/Reply/Dismiss MicroButtons out of reach on every compact
row, local and remote alike. Moved the label onto the host badge (a
leaf that only exists on remote rows), composed from the existing
badge and unreachableNote functions rather than a third one. Also
matched the account-label chip's .tertiary foreground on the host
badge in both densities.
A trailing record bigger than the 64 KB window left no newline after the
seek, so tail_of returned "" and the record shipped with an empty tail.
On the Mac ClaudeSessionParser.parse("") returns nil, the record is
skipped, its id never reaches incomingIds and SessionStore.sync deletes
the row. Nothing warned on either side, and the window is open exactly
while a session is busiest: 20 of 30 in-window transcripts on this Mac
hold a record over 64 KB right now.

The guard already existed locally and was not ported. tail_of now doubles
the window up to the same 4 MB ceiling ClaudeCodeMonitor.maxTailBytes
uses, and returns sentinels rather than "" when it cannot get a complete
record, so the caller omits the record and names the path in a warning.
A row that is absent because we said so is recoverable; a row silently
deleted is not.

Tests are python, beside the helper, because the case is only
reproducible against a real file: python3 scripts/test-swarmbar-helper.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both ssh invocations sent stderr to /dev/null, so a missing NOPASSWD
rule, an unauthorized key, a host key mismatch, a missing python3, a
helper syntax error and a genuinely down host all collapsed into
"connection closed by <alias>" and a row reading "Cannot reach <host>".
Both now capture stderr and put its first non-empty line, truncated to
200 characters, into the NSLog and into the unreachable payload.

Reading it is deliberately not availableData: that blocks until bytes or
EOF, which hangs on a live quiet child and never returns at all when the
spawn itself failed, since the parent still holds the write end. One
non-blocking read takes what is there and nothing else.

The install ssh also had no keepalive and an unbounded waitUntilExit().
A connection going half open after the handshake (laptop sleep, Tailscale
dropping mid transfer) is not covered by ConnectTimeout, and default TCP
keepalive takes about two hours to notice. That blocked the host's
monitor loop BEFORE its do/catch, so the host never polled, never
updated reachability, and its rows froze with no note: the one failure
here that was silent rather than visible. It now carries the channel's
ServerAliveInterval/ServerAliveCountMax and gives up after 30 seconds,
three times ConnectTimeout for a handshake plus a 9 KB write.

Snapshot parsing also moves off the main actor, mirroring
ClaudeCodeMonitor.start: it was running roughly a megabyte of string
work and JSONSerialization on the UI actor every 10 seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five smaller fixes from the branch review.

Remote rows offered actions that target LOCAL terminals. A remote Claude
session can land in Needs you with Reply, and a finished-turn row with a
live process gets Reply too; both call TerminalFocuser against this Mac.
Mostly that failed safe with the wrong message, but not always: kimiPid
matches a local process by cwd, and the fleet includes a macOS host whose
paths have the same /Users/zach/... shape as this Mac's, so `open -a
iTerm <path>` can land in a real local directory and imply you have been
taken to the session. Reply, Approve, Deny, Open in Terminal and the
double click behind it are now gated on session.host == nil, hidden
rather than disabled. Approve and Deny are gated even though the parser
cannot yet produce a remote approval, so phase 3 does not inherit it.
Dismiss stays: it only writes SwarmBar's own store.

SIGPIPE is ignored at startup. Nothing in the codebase handled it, and
this branch adds two writes into a child's stdin, the install one landing
immediately after try task.run(). A fast failure closes the read end
first and the default disposition kills the menu bar app outright; now
the same case arrives as EPIPE through paths that already throw.

The elapsed timer on an active remote row measured the wrong thing:
startedAt and lastActivityAt were both the file mtime, so a session that
had been running for days read as seconds old, while local uses the
file's creation date. The helper now ships `created` (protocol 2,
additive and optional on the Mac, falling back to mtime). It is
st_birthtime where the platform has it, which covers the Darwin fleet
host; Linux has no birthtime through os.stat before python 3.12, so
st_ctime stands in there and still tracks writes. Fixture updated
structurally, not re-captured.

SessionStore.sync's doc comment said it replaced one tool's sessions with
no mention of host, which is the trap the plan warns about: a monitor
that omits host claims the local rows and deletes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The notification click handler in SwarmBarApp calls
SessionStore.openInTerminal directly, bypassing the row views' existing
allowsLocalTerminalActions gate. A remote Claude session reaching
waitingApproval posts a banner by default, and clicking it would have
focused a local terminal via TerminalFocuser, which only ever targets this
Mac. Guard inside openInTerminal itself so the guarantee holds regardless
of caller.

Adds SessionStore.canOpenInTerminal as a pure decision the guard consults,
since TerminalFocuser.focus has no injection seam to observe directly, and
tests it against a remote and a local session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ace helper warnings

Three unrelated residual fixes from review:

- SSHChannel.snapshot() now drains the stderr pipe on the healthy path too,
  not only on failure. Before the recent fix stderr went to /dev/null and
  could never back up; now a long-lived connection could otherwise fill
  the pipe (~64 KB) and stall ssh with no timeout on readLine to notice.

- Two comments (swarmbar-helper.py's created_time docstring and
  RemoteSnapshot.swift) claimed the creation timestamp fallback is never
  later than the last write. False on Linux: POSIX write() updates both
  mtime and ctime, but a metadata-only change (chmod, chown, rename, a
  hardlink) bumps ctime without touching mtime, so ctime > mtime is
  reachable. Comments corrected to say so; no behavior change, and
  ElapsedTimeText's existing zero-clamp already handles the visible effect.

- RemoteHostMonitor now logs the helper's warnings whenever non-empty,
  not only when processesFailed is true, since the helper also warns
  about a skipped backup-looking root, an unreadable transcript, and a
  record beyond the 4 MB ceiling it omitted. Logged only when the warning
  set changes from the previous snapshot for that host, so a persistent
  condition doesn't spam the log every ten seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@umzcio
umzcio merged commit 565752b into main Sep 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant