Repository navigation
Conversation
Stamps arrived as the Mac's naive wall clock ("2026-09-07 14:33:12") and
were compared against the Linux clock — the same string only while both
machines sat in one timezone. A Mac an hour ahead put every read mark ahead
of every message, so nothing ever counted as unread; an hour behind and the
backlog re-toasted. And once a year, in the DST fall-back hour, the Mac's
own clock repeats: two messages an hour apart carried the SAME stamp, so
ordering, the watermark and "newer than the mark" stopped meaning anything
for that hour. Every `a.ts < b.ts` in the collector was already assuming
lexical order equals chronological order; only UTC actually provides it.
imsg's JSON now emits ISO-8601 UTC (fmt_ts); the plain-text renders keep the
Mac's clock (fmt_ts_local), because a person reading `imsg recent` wants the
time they remember. On the Linux side localNowTs() became nowTs() in the same
format, and every label converts to the READER's zone at render: localDay()
and formatStamp() in thread.ts, stampMs()/localDay()/fmtTime() in BlipView.
Day dividers no longer slice the date out of the string — that is UTC's day,
and the divider belongs at your midnight.
Upgrading is covered from both ends, and both halves are load-bearing
together: loadState() migrates the marks a pre-UTC release wrote, and stamps
are normalised at the two fetch doors so a Mac still on the old bridge keeps
working. Migrating the marks alone would have sorted every message below
every mark (" " < "T") and silently emptied the badge and the toasts.
The suite runs pinned to TZ=UTC, where all of this is invisible; the new
timezone.test.ts and bridge/mac/test_wire_time.py set their own zones and
are what actually cover it. Verified live against the gateway Mac on both
the old and the new bridge: 298 threads, unread unchanged, no toast storm
on migration, and 23:47:54Z rendering as 6:47 PM in CDT.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
A GIF was flattened twice on the way to the screen. fetch.ts treats any image/* under the auto-fetch cap as a preview candidate and asks the Mac to resample it with sips — which collapses an animation to one frame, so a 1.4 MB GIF landed as a 198 KB JPEG with the motion already gone. Animated formats now skip that path entirely and cross as their original bytes, into the `orig` cache slot a click already shares. The second flattening was in QML: Image paints a single frame whatever you give it. Animated mimes now render through AnimatedImage. Stills deliberately stay on Image — it is the one that applies autoTransform, the EXIF fall-back for anything cached before fetch.ts started baking orientation in. Sizing is computed once on a wrapping Item so the two renderers can never disagree about the delegate's width, and only the active one takes a source, or every photo would decode twice. AnimatedImage honours sourceSize (measured), so the decode ceiling still applies to a huge GIF. imageMetrics also learned the GIF header: it reported 0×0 before, which left the bubble with nothing to compute a height from. Verified against a real 498×372, 18-frame, infinitely-looping GIF in chat.db: the bridge now delivers all 1,357,761 bytes instead of a 198 KB still, and the panel reports animated=true playing=true frameCount=18 with the frame counter cycling 0→6→12→0 while ten successive screenshots of the bubble come back as ten distinct images. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
…opped Dropping five photos attached one and discarded four in silence — the drop handler took urls[0] and draftPath was a single string. A drop that loses most of what you dropped, with no message, is worse than one that refuses. Drafts are a list now. Drag-and-drop queues every file it was given, /attach and Ctrl+V append rather than replace, and each chip removes only itself. Sends go out one part per file in queue order through the existing send-file.ts path (unchanged: still one file per invocation, still stdin, still refusing groups whose guid is unknown). The caption rides the first part alone, or five files would post the same sentence five times. Two things a batch has to survive. The service is captured when it STARTS, never re-read from root.active per part, so switching threads mid-send cannot push a later part onto a different service (war room #2). And a failing part stops the batch, leaves the remaining files attached and says how many are left, instead of making the user work out which of five actually went. Capped at ten files: a stray drop of a whole folder should be refused, not turned into eighty sends. Chips are one per row for the reason the received ones are — a RowLayout of N sums implicit widths and drags the conversation column past the panel edge, taking every right-aligned element with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
Blip felt subtly laggy rather than slow. Measuring said why: nothing is slow, everything pays startup. A bridge call cost ~133 ms while the SQL underneath ran in ~1 ms — 16 ms ssh round trip, ~25 ms for blip-dispatch's Python start, ~20 ms for imsg's, 27 ms to open a 218 MB chat.db. Worse, the shim fired a SECOND full ssh round trip before every call as a connectivity probe — 41 ms, ~31% of the total — bought only to turn a transport failure into a friendlier sentence. ssh reports that itself as 255, and every caller (collector, thread, fetch, avatar, search, send-file, contact-search) has always treated 255 exactly like 69. The probe is gone; the real call carries the news and the shim translates 255 → 69, so the documented contract is unchanged and ssh's own reason now reaches stderr, which is more than the probe's one-line tail ever carried. It also retires the trap that came with it: the probe HAD to be `ssh -n` or it ate stdin and silently emptied --file-stdin payloads. Verified with a stub ssh that stdin still reaches the remote command intact, that a transport failure exits 69, and that any other exit code passes through untouched. The push debounce came down from 250 ms to 60 ms. It is paid on every received message and on every send — Messages writing the row is itself a chat.db change, so the watch ping is what resolves the "Sending…" bubble — and at 250 ms it was more than twice the cost of the 116 ms fetch it was coalescing. A burst still costs one fetch. The post-send reload fell from 600 ms to 250 ms and is now the fall-back for a downed watcher rather than the mechanism. Measured, warm, medians: bridge call 132 → ~95 ms, open a conversation 223 → ~185 ms, poll 160 → ~118 ms, deep poll 707 → ~590 ms. Two things the measurements talked us out of, both recorded in CLAUDE.md so nobody re-tries them: replacing cmd_chats' 300-query N+1 with a single window function is SLOWER (70 → 107 ms, the per-chat lookups are indexed), and bundling the deep poll's three sequential calls into one would be undone by the persistent channel coming next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
With the preflight probe gone a bridge call still paid ~90 ms before it read
a single row: the ssh round trip, blip-dispatch's Python start, imsg's own,
and 27 ms to open a 218 MB chat.db — for SQL that runs in about a
millisecond. Startup, not work, was the whole of the lag.
`imsg serve` answers many requests on one process with chat.db already open.
Requests are re-dispatched through build_parser(), the SAME parser the CLI
uses, so there is no second command table to drift into a second security
surface, and only read-only queries are reachable (SERVE_ALLOWED): attachment
and avatar stream binary and would park the channel behind a 100 MB photo,
watch blocks forever by design, serve would recurse. Framing is a
{"status","len"} line plus exactly len bytes — counted, not escaped, or a
122 KB payload would be re-encoded into a JSON string. A request carries its
stdin separately, so message text still never rides argv.
blip-bridged holds two of those channels open behind a unix socket in
XDG_RUNTIME_DIR. Two, not one: Blip refreshes the thread list and reloads the
open conversation in parallel on every push ping, and a single channel would
have queued the reload behind a 259 ms `chats` call — making the case it was
meant to help slower. The leader BarWidget runs it, exactly as it already
runs the push watcher, which is why Blip still needs no daemon of its own.
Nothing depends on it. No socket, no socat, a daemon that died mid-request, a
frame that will not parse, a short body — bridgeRun() falls through to the
ordinary one-shot ssh path on any fault at all. A caller that passes its own
runner never touches the socket, which is what keeps the existing suite
honest: those tests inject a runner and assert on real argv.
Measured end to end, medians: opening a conversation 223 → ~105 ms, a poll
160 → ~36 ms, a deep poll 707 → ~370 ms. On the channel itself, recent 150
went 100 → 18 ms and groups 145 → 26 ms; `chats` stays ~260 ms because it is
compute-bound (179 ms SQL, 104 ms name resolution), not startup-bound.
One trap worth the comment it now carries: `encoding: "buffer"` throws
ERR_UNKNOWN_ENCODING in Bun's spawnSync, the catch swallowed it, and the fast
path silently never ran while every call quietly took the slow one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
With startup amortised by the serve channel, what was left in `chats` was compute: 179 ms of SQL and 104 ms resolving names. The name half was algorithmic. A handle with no exact last-ten key fell back to comparing it against every saved number, so 298 conversations cost 60,112 _same_number calls — 46 ms a request, and it grows with the address book. Cards are bucketed by their last seven digits now. That is sound rather than convenient: _same_number only matches when one national number is a SUFFIX of the other with a floor of seven digits, so the last seven always agree, and a card that does not share them cannot match at any calling code. The stripped trunk-zero variant is bucketed too, since the rule tries the card both ways. Verified against the old full scan over all 750 distinct handles in a real chat.db: zero mismatches, largest bucket 2 cards instead of 442. That comparison is the check to repeat if this is ever touched — CLAUDE.md now says so, because these matching rules have been got wrong twice before. name_for also memoises per handle, and group clusters and pins are memoised across requests now that a process outlives one. They key on what they actually derive from — the chat table's (count, max ROWID) and the pinning plist's mtime — never on chat.db's mtime, which changes on every message and would have cached nothing during exactly the busy minute that matters. The Contacts index gains an invalidation for the same reason in reverse: a one-shot run rebuilt it every time, while the serve channel would otherwise answer with the names it started with until it died. cmd_chats 292 → 215 ms; a rich thread load 72 → 52 ms (the cluster cache). End to end against where this started: opening a conversation 223 → 83 ms, a poll 160 → 39 ms, a deep poll 707 → 342 ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
The verdict predates the OS. Enumerating Messages' menu bar over System Events on the gateway Mac turns up real menu items for three of the four things that note rules out: Edit ▸ Tapback Message…, Edit ▸ Reply to Message…, Edit ▸ Edit Last Message…, plus Send Later…. A menu item is scriptable, which is precisely how imsg-read overturned the old "marking read on the Mac is impossible" note. Typing indicators have no menu item and stay out. Nothing is claimed to work yet, and the note says so. All four read enabled=false from the background, which by our own read-push finding is evidence of nothing — AppKit validates menus against the ACTIVE app's responder chain — so feasibility has to be retested with Messages frontmost. The harder obstacles are recorded with it: the item acts on the SELECTED message and selecting an arbitrary bubble from Linux is the unsolved part, the "…" implies a picker to navigate for the emoji, Messages must come forward so it steals focus, and a group still cannot be addressed at all. Recording this because a stale "not possible" is the kind of note that stops someone looking for years. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 586a4def28
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| k, v = line.split("=", 1) | ||
| v = v.strip("\"'") | ||
| if k in conf: | ||
| conf[k] = v |
There was a problem hiding this comment.
Validate config before constructing the remote command
When the dedicated key is absent or unreadable, _spawn() interpolates python and remote_bin into a remote shell command, but this new parser accepts those values verbatim instead of applying the shim's path validation. A malformed or hostile bridge.conf can therefore include shell metacharacters and execute arbitrary commands on the Mac through the general SSH path, reopening the bad-config threat the parsed shim was intended to close. Apply the shim's allowlist validation before starting any channel.
AGENTS.md reference: AGENTS.md:L26-L29
Useful? React with 👍 / 👎.
| */ | ||
| export function toUtcStamp(ts: string): string { | ||
| if (!ts || ts.includes("T")) return ts; // already UTC, or unset | ||
| const ms = Date.parse(ts.replace(" ", "T")); // naive → this machine's local |
There was a problem hiding this comment.
Migrate legacy marks using the Mac timezone
On an upgrade where the Mac and Linux machines use different timezones, legacy state timestamps are Mac-local wall times, but this parses them in the Linux process's timezone. The resulting readMark can be shifted by the full offset: if shifted forward, new messages remain suppressed for hours; if shifted backward, previously read history is resurrected in the unread ledger. The migration tests only convert a source stamp in the same zone used by Date.parse, so they do not cover the cross-machine upgrade this change targets.
Useful? React with 👍 / 👎.
|
Thanks for this, @zachwilke. All seven commits are worth having, and the UTC-on-the-wire change fixes an open ROADMAP item (Mac-local wall-clock stamps compared against the Linux clock). It no longer merges: main has moved a long way (2.5.0 is being cut now), and GitHub reports conflicts in BlipView, collector and thread. As one +2182/−461 branch it's also hard to review safely, because the parts carry very different risk. Could you split it into separate PRs, each rebased on current
Smaller PRs will also get reviewed and merged sooner. |
|
Fred — thanks for the split request. #47 is too large to review safely against current
This mega-PR can be closed in favor of those five. I am leaving that to you / Zach rather than closing it from here. |
|
@nixfred Closing this mega-PR in favor of the five smaller ones above — thanks again for the clear split guidance. Looking forward to your review on those (UTC first is ready whenever you are). |
The live discussion is on that issue (AX-on-balloons, SIP on); nothing from it is in-tree. Fred asked the docs slice of #47 to reference it. Co-authored-by: zachwilke <zachwilke@users.noreply.github.com>
When the dedicated key is absent, _spawn() interpolates python and remote_bin into a remote shell command. Codex's P1 on #47: a malformed or hostile bridge.conf could inject metacharacters and run on the Mac through the general ssh path, reopening the threat the parsed shim closed (audit #7). The same regexes, before any channel starts; exit 78. Co-authored-by: zachwilke <zachwilke@users.noreply.github.com>
Seven commits: a correctness fix for timestamps, two media/compose fixes, three
performance changes, and one docs correction. Rebased on
main(including thecompose Flickable and per-conversation drafts). CI-equivalent checks are green
locally: 420
bun test, all seven Mac-tool tests includingtest_cluster,py_compile,bash -n. Deployed and running on my Omarchy box.Three things below need your call, not just review — please read those first.
What's in it
1. Time crosses the bridge as UTC (
688f2cb)Stamps arrived as the Mac's naive wall clock and were compared against the
Linux clock — the same string only while both machines sit in one timezone. Two
ways it lied. A Mac an hour ahead put every read mark ahead of every message,
so nothing ever counted as unread. And once a year, in the DST fall-back hour,
the Mac's own clock repeats: two messages an hour apart carry the SAME stamp,
so ordering, the watermark and "newer than the mark" stop meaning anything for
that hour. Every
a.ts < b.tsin the collector assumes lexical order ischronological order; only UTC actually provides it.
imsg's JSON is now ISO-8601 UTC; the plain-text renders keep the Mac's clock(
fmt_ts_local), because a person readingimsg recentwants the time theyremember. Labels convert to the reader's zone at render, so day dividers still
break at your midnight.
Upgrading is covered from both ends, and both halves are load-bearing together:
loadState()migrates marks a pre-UTC release wrote, and stamps are normalisedat the two fetch doors so a Mac still on the old bridge keeps working.
Migrating the marks alone would sort every message below every mark (
" "<"T") and silently empty the badge and the toasts.The suite runs pinned to
TZ=UTC, where all of this is invisible; the newtimezone.test.tsandbridge/mac/test_wire_time.pyset their own zones andare what actually cover it.
2. GIFs move (
1feab93)An animated GIF was flattened twice.
fetch.tstreats anyimage/*under theauto-fetch cap as a preview candidate and asks the Mac to resample it with
sips, which collapses an animation to one frame — a 1.4 MB GIF reached Linuxas a 198 KB JPEG. And a QML
Imagepaints one frame whatever you hand it.Animated mimes now skip the resample and render through
AnimatedImage; stillsstay on
Image, which is what appliesautoTransform(the EXIF fall-back).AnimatedImagehonourssourceSize(measured), so the decode ceiling stillapplies.
imageMetricsalso learned the GIF header — it reported 0x0 before.3. A message can carry several files (
20f8bc1)Dropping five photos attached one and discarded four in silence: the handler
read
urls[0]and the draft was a single path. Drafts are a list now, ship onepart per file, caption on the first only, service captured when the batch
starts so switching threads mid-send cannot push a later part onto a different
service. A failing part stops the batch and leaves the rest attached. Capped at
ten. Chips are one per row, for the implicit-width reason the received ones are.
Note the rebase:
mainnow usesdraftsfor the per-conversation TEXT draftmap, so the attachment list is
attachDrafts/attachCount/addAttachment(s)/removeAttachment/clearAttachments. Two propertiesnamed
draftswould have been a duplicate-property error.4. Performance (
0727e91,5b8b01e,934ee1f)Nothing was slow; everything paid startup. A bridge call cost ~133 ms while the
SQL underneath ran in ~1 ms: 16 ms ssh, ~25 ms
blip-dispatchPython start,~20 ms
imsg's own, 27 ms to open a 218 MB chat.db.connectivity probe — 41 ms, ~31% — bought only for a friendlier error. ssh
reports transport failure as 255 and every caller already treats 255 exactly
like 69, so the probe is gone and the shim translates. This also retires the
trap that came with it: the probe HAD to be
ssh -nor it ate stdin andemptied
--file-stdinpayloads. Verified with a stub ssh that stdin stillreaches the remote command, that a transport failure exits 69, and that other
exit codes pass through.
imsg serveanswers many requests on one process with chat.db already open,re-dispatched through the SAME parser the CLI uses so there is no second
command table to drift. Read-only queries only; attachment/avatar stream
binary and would park the channel behind a 100 MB photo,
watchblocks bydesign.
blip-bridgedholds two such channels behind a unix socket.numbers — 298 conversations meant 60,112
_same_numbercalls. Bucketed bylast-seven-digits, which is sound rather than convenient: the rule only
matches on a suffix with a seven-digit floor, so the last seven always agree.
Verified identical to the old full scan over all 750 distinct handles in a
real chat.db, zero mismatches.
Measured, warm medians: opening a conversation 223 -> 83 ms, a poll
160 -> 39 ms, a deep poll 707 -> 342 ms, a bridge call 132 -> 14 ms.
The push debounce came down 250 -> 60 ms and the post-send reload 600 -> 250 ms.
Three "obvious" fixes the measurements talked me out of, all recorded in
CLAUDE.md so nobody retries them: replacing
cmd_chats' 300-query N+1 with awindow function is slower (70 -> 107 ms; the per-chat lookups are indexed),
bundling/compiling the TypeScript changes nothing (44 vs 44 ms; a compiled
binary is worse at 53 ms), and
python3 -Ssaves 2 ms.5. Docs correction (
586a4de)CLAUDE.mdsaid sending tapbacks needs SIP-off injection. That predatesmacOS 26: enumerating Messages' menu bar over System Events finds real items —
Edit > Tapback Message...,Reply to Message...,Edit Last Message....That is the same shape
imsg-readused to overturn "marking read isimpossible". Nothing is claimed to work; the note records what is still
unproven (the items read
enabled=falsefrom the background, which by our ownread-push finding is evidence of nothing; the item acts on the SELECTED
message, and selecting an arbitrary bubble from Linux is the open problem).
Recorded because a stale "not possible" stops people looking for years.
Your call
blip-bridgedis a Linux-side daemon, and ROADMAP retiredblipd. Ithink this is a different animal — a transport multiplexer, not an
application daemon; it holds no state, the leader BarWidget supervises it
exactly as it already supervises the push watcher, and it is an accelerator
that nothing depends on: no socket, no
socat, a dead daemon or anunparseable frame, and
bridgeRun()falls through to the ordinary one-shotpath. But it is your architecture call, and I would rather you reject it
than discover it later. The other two perf commits stand alone without it.
bridge/mac/imsgis vendored, and I changed it a lot (UTC,serve, thecontact index). CONTRIBUTING says fixes to
imsgare welcome here, but thesearguably belong upstream in claude-on-mac, and
BRIDGE-VERSIONnow names apin the file no longer matches.
sync-bridge.shwill flag the divergenceand stage an
.upstreamcopy rather than clobbering, which is the intendedbehaviour — but you may want these landed in claude-on-mac first and
re-vendored instead.
socatbecomes a soft dependency for the fast path only. Without itBlip behaves exactly as before, just slower;
blip-setupsays so ratherthan failing. Happy to drop it for a different client if you would rather
not add one.
Not verified
is proven animating from instrumentation (
playing=true frameCount=18, framecounter cycling, ten successive screenshots all distinct), but I have not
photographed the chip stack.
contacts). The timing numbers are from that machine.
self-thread or not at all, per CONTRIBUTING.
Happy to split this into separate PRs if the bundle is too big to review; the
perf commits are sequential and share
bridge/mac/imsg, which is why theyarrived together.
🤖 Generated with Claude Code
https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS