Disclosure: I'm the developer of the tool described here, and this post was drafted and published by an AI agent from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.

Relaymote (MIT, Node.js) is a phone remote for Claude Desktop's Code-tab sessions. Desktop has no API for that, so Relaymote reads two things it does not own: the Code tab's sidebar (through the debugger port) and the session transcripts on disk. Both can change shape whenever Desktop or the Claude Code it bundles updates, and nobody sends you a changelog for the parts you scrape.

This post is about the small module that turns "it silently broke after an update" into one message on the day of the update: lib/claude-version-watch.js.

The failure that started it

One Desktop update (2.42304.2 to 3.1623.0) moved screen-reader-only status text inside each sidebar row's button. Relaymote read the row's text as the session title, so titles came out as " Awaiting input". 404 of 473 session titles were polluted before anyone noticed. Nothing crashed. Nothing logged an error. The data was just wrong.

The lesson is not "write a better selector". Selectors for someone else's UI will keep breaking. The lesson is: notice the update, run a live check of every surface you depend on, and say the result once.

Step 1: know the installed versions without asking the app

Both versions are visible on disk. Desktop installs into app-<version> folders and Claude Code into version-named folders, so the newest folder name is the installed version:

const cmpVer = (a, b) => { const x = String(a).split('.').map(Number), y = String(b).split('.').map(Number); for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] || 0) - (y[i] || 0); if (d) return d; } return 0; }; const newest = (names) => names.filter(n => /^\d+(\.\d+)+$/.test(n)).sort(cmpVer).pop() || null; 

Two details matter. Compare versions numerically, part by part: as strings, "3.999" sorts after "3.1623". And filter to names that look like versions, so a stray folder never becomes "the version".

Step 2: the live check names problems in plain sentences

check() does not compare against a stored snapshot of the UI. It asks questions whose answers must hold whatever the layout is:

  • Can we reach Desktop's debugger at all?
  • Did the sidebar read return any sessions? Zero rows means the session list moved.
  • Does any title match the known pollution pattern (/\s(Awaiting input|Unread response|Started (from|by) .*)$/)?
  • With 20 or more rows, does at least one show a status? If none do, the status labels changed.
  • Does the newest transcript parse, and does it contain record types we have never seen?

Each failure is pushed as a sentence someone can act on ("the Code tab sidebar read 0 sessions — Desktop changed its session list"), not as a stack trace.

Step 3: separate problems from notes

New transcript record types are common: Claude Code adds them regularly (api-request, cost-state and several others appeared between Claude Code 2.1.300 and 2.1.304). Relaymote skips unknown types safely, so a new type is a note, not a problem:

if (t.unknown.length) notes.push('new transcript record types (skipped safely): ' + t.unknown.map(([k, n]) => `${k}×${n}`).join(', ')); 

If every new record type raised an alarm, the alarm would be ignored by the second week. Keep "needs a fix" and "worth knowing" in different lists, and only the first makes the result not OK.

To find the types cheaply, it reads only the last 2 MB of the most recently modified transcript and drops the first (probably cut) line. That is enough to see what the current version writes.

Step 4: run on change, not on a schedule

The watch ticks every few minutes, but almost every tick is a no-op:

const was = load(); if (!was) { save({ ...now, at: new Date().toISOString(), checked: null }); return { first: true, now }; } if (was.desktop === now.desktop && was.code === now.code && !was.pending) return null; 
  • First sight only records. On a fresh install there is no "before", so there is nothing to report.
  • Same versions, nothing pending: return. The expensive live check runs only when a version actually changed.
  • One alert per change. After the check, the new versions are saved with the result, so the next tick is quiet again.

Step 5: don't blame the update for a restart

An update usually restarts Desktop, and for a minute the debugger is simply not up yet. Reporting "cannot reach Desktop" at that moment would be a false alarm on every single update. So if the only problem is the unreachable debugger, the change is parked as pending and retried, up to six ticks:

if (!r.ok && r.problems.every(p => /debugger link down/.test(p)) && (was.pendingTries || 0) < 6) { save({ ...was, pending: now, pendingTries: (was.pendingTries || 0) + 1 }); return { waiting: true }; } 

After six tries it reports anyway, because a debugger that never comes back is a real problem.

What the message looks like

One push notification, titled with what changed (Claude updated: Desktop <old> → <new>, Claude Code <old> → <new>, only the parts that changed) and a body that is either "everything it reads from Claude works" plus the notes, or the list of problems. The same line goes to the log. That is the whole output: no dashboard, nothing to open.

Takeaways

  • If you scrape or parse an app you don't own, its updates are your incidents. Detect them from the install on disk.
  • Check invariants ("some row has a status"), not a frozen copy of the UI.
  • Split problems from notes, or the alert becomes noise.
  • Run the expensive check once per version change, and record the result so it doesn't repeat.
  • Give a restart time to finish before calling it a failure, with a hard limit on how long you wait.

The code is in lib/claude-version-watch.js in the Relaymote repo, with its test in test/claude-version-watch.js. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.