Repository navigation
Release SwarmBar 0.1.5 - #2
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.