claude-mobile-bridge in the Claude desktop app: Telegram messages into Code-tab sessions, a status band with expandable details, and /bridge status|doctor.

claude-mobile-bridge inside the Claude desktop app (Code tab):
⚠️ bridge bot not answering, ⚠️ no channel-relay for this session / channel-relay not answering, ⚠️ two pollers on this session's relay./bridge status: bot, this session's topic (with a t.me link), relay and poll state.● bridge · connected · <topic> · 📱 2m ago, or the warning). details ▾ expands it: bot, topic with an Open topic link, relay, last phone message, the latest 5 phone messages, and the last doctor run, with Run doctor and Refresh buttons. In the terminal the band shows only on a warning, or for 15 minutes after a phone message./bridge doctor: checks the bot answers, finds duplicate bot processes, whether the bot tracks this session and has a topic for it, whether the relay is up and polled, old plugins still enabled, bridge-format missing, and recent [ERROR]s in bot.log. Each problem says what to do.Install it with its companion bridge-format, which shows relayed prompts as 📱 <message> instead of the raw <channel …> tag. It has to be a separate plugin: $.prompt.submit takes no hidden context, and the engine keeps a plugin's own hooks off the prompts it submits.
Requires the relay registered as a user MCP server (see the main README) and the bot running. Then, from a terminal:
claude plugin marketplace add azaidi94/claude-mobile-bridge
claude plugin install bridge@claude-mobile-bridge
claude plugin install bridge-format@claude-mobile-bridge
From a local clone, use the clone's path in place of azaidi94/claude-mobile-bridge. In the desktop app: + next to the prompt box → Plugins → Add marketplace / Add plugin. Installed at user scope, it loads in every new session; sessions already open need a new session or /reload-plugins.
Replacing channel-pull: uninstall channel-pull and channel-pull-format first. With both installed the two pollers keep cutting each other's long-poll short; bridge backs off, and its band and /bridge doctor say so. If you added the marketplace before bridge existed, run claude plugin marketplace update claude-mobile-bridge before installing.
claude plugin uninstall channel-pull@claude-mobile-bridge
claude plugin uninstall channel-pull-format@claude-mobile-bridge
Telegram ─► bot ─TCP─► channel-relay ─unix socket─► bridge ─► $.prompt.submit ─► bridge-format (📱)
bot.sock (status) ◄────────────────── bridge (band, /bridge)
claude's command line. Without the channels flag it serves a long-poll inbox on ~/.claude-mobile-bridge/relay-<pid>.sock (mode 0600), named as pullSocket in its port file, and queues every message there (up to 10 minutes: the relay's request-id lifetime). The mod finds the newest port file whose sessionId matches its session and long-polls GET /inbox on a $.clock.after timer chain (8s per poll, retry every 5s); a reload drops the timers, so only one copy ever polls. Each message is submitted asUser, wrapped in the same <channel source="channel-relay" chat_id=… request_id=… user=…> tag a CLI session gets, so reply, attachments and ask_remote work as in the CLI. In a cc session (channels on) the relay offers no socket and the mod stands down.~/.claude-mobile-bridge/bot.sock (mode 0600, read-only: GET /status, GET /session/<id>; see src/control/server.ts), what it knows about this session.CLAUDE_TELEGRAM_STATE_DIR (the bot's and relay's state folder) and CLAUDE_TELEGRAM_LOG_DIR are read from this session's environment: if you set them in the bot's .env only, set them for Claude too, or the mod looks in the defaults.
Messages that arrive mid-turn queue and run once the session goes idle (channel notifications in the CLI land in the running turn instead).
/bridge doctor say when it's down.claude/channel/permission): these need channels. In a session that isn't in bypass mode, an approval prompt can only be answered in the app./clear, /compact, /context and /model injection, the /claude menu's commands, /skills injection, and tapping answers to a native AskUserQuestion. Questions asked with ask_remote work.claude plugin validate plugins/bridge
claude plugin test plugins/bridge
claude plugin test plugins/bridge-format
bun run test runs both when the claude CLI is installed. Pure logic (prompt format, band, status and doctor text) is in hooks/core.ts; hooks/register.tsx does the I/O. Relay side: src/mcp/channel-relay/pull-inbox.ts; bot side: src/control/server.ts. Bump version in plugin.json on every change: installs from GitHub only update on a new version.
hooks/register.tsx 472 lines1import { atom, read, update } from "claude-code";
2import type { Register, StateDollar } from "claude-code";
3import type { BridgeStatus, DoctorRun, FeedEntry } from "../types";
4import {
5 FORMAT_PLUGIN,
6 LEGACY_PLUGINS,
7 addToFeed,
8 bandFor,
9 botPidsFrom,
10 clockTime,
11 doctorLines,
12 doctorSummary,
13 doctorTone,
14 formatDoctor,
15 formatStatus,
16 bridgeHealth,
17 ago,
18 pickRelay,
19 pidsInCwd,
20 statusRows,
21 toChannelPrompt,
22 type Inbound,
23 type RelayPortFile,
24 type TopicView,
25} from "./core";
26
27/**
28 * claude-mobile-bridge inside a Claude session — built for the desktop app,
29 * whose sessions can't load channels:
30 * - pulls Telegram messages from this session's channel-relay and submits
31 * each as a prompt (the companion bridge-format shows it as `📱 <message>`:
32 * the engine keeps a plugin's own hooks off its submits, so not here);
33 * - a band above the prompt when the bridge needs attention;
34 * - `/bridge status` and `/bridge doctor`, read from the bot's control socket;
35 * - on desktop the band always shows one status line, with details (both,
36 * and the latest phone messages) a press away.
37 */
38
39// The relay caps a long-poll at 8s.
40const POLL_MS = 8_000;
41const RETRY_MS = 5_000;
42const STATUS_EVERY_MS = 15_000;
43/** A CLI session's relay notifies directly; look again this often in case that changes. */
44const CHANNELS_RECHECK_MS = 30_000;
45/**
46 * An empty answer sooner than this means another poll superseded ours (the
47 * relay serves one waiter): back off instead of fighting it in a tight loop.
48 */
49const SUPERSEDED_MS = POLL_MS / 2;
50/** Failed polls in a row before the band says the relay isn't answering. */
51const ERRORS_BEFORE_WARN = 3;
52const BOT_TIMEOUT_MS = 2_000;
53/** Phone messages the expanded band lists (the feed keeps more). */
54const BAND_FEED_ROWS = 5;
55/** Redraw the band this often, for ages and the startup grace. */
56const REDRAW_MS = 30_000;
57
58const status = atom(
59 { plugin: "bridge", key: "status" } as const,
60 {
61 bot: "unknown",
62 botPid: null,
63 relay: "unknown",
64 topic: null,
65 lastPhoneAt: null,
66 startedAt: 0,
67 } as BridgeStatus,
68);
69
70const feed = atom(
71 { plugin: "bridge", key: "feed" } as const,
72 [] as FeedEntry[],
73);
74/** The band's details shown; this session only, collapsed at start. */
75const expanded = atom({ plugin: "bridge", key: "expanded" } as const, false);
76const doctorRun = atom(
77 { plugin: "bridge", key: "doctor" } as const,
78 { at: null, text: null, isRunning: false } as DoctorRun,
79);
80
81/**
82 * Set fields over the current value (merged inside the update, so concurrent
83 * patches from the poll and status timers don't overwrite each other), and
84 * skip the write — and the band's redraw — when nothing changed.
85 */
86const patch = async ($: StateDollar, fields: Partial<BridgeStatus>) => {
87 const cur = await read($, status);
88 if (JSON.stringify({ ...cur, ...fields }) === JSON.stringify(cur)) return;
89 await update($, status, (now) => ({ ...now, ...fields }));
90};
91
92export const register: Register = (on) => {
93 /**
94 * Set by session.start, closing over its `$`: `/bridge doctor`'s checks
95 * (the last run kept for the band), and a status check.
96 */
97 let runDoctor: (() => Promise<string>) | undefined;
98 let refreshNow: (() => Promise<void>) | undefined;
99
100 on("session.start", async ($, e, next) => {
101 const started = await next(e);
102 await $.command.register({
103 name: "bridge",
104 description: "claude-mobile-bridge: status, or doctor to diagnose",
105 argumentHint: "[status|doctor]",
106 immediate: true,
107 });
108 const now = await $.clock.now();
109 await patch($, { bot: "unknown", relay: "unknown", startedAt: now });
110 // A reload mid-run cancels the run: don't leave "Running…" up.
111 await update($, doctorRun, (r) => ({ ...r, isRunning: false }));
112 // Ages ("2m ago") and the startup grace change with no state write.
113 $.clock.every(REDRAW_MS, () => $.ui.invalidate("ui.render"));
114
115 const home = async () => (await $.env.get("HOME")) ?? "";
116 const stateDir = async () =>
117 (await $.env.get("CLAUDE_TELEGRAM_STATE_DIR")) ??
118 `${await home()}/.claude-mobile-bridge`;
119
120 const findRelay = async (): Promise<RelayPortFile | undefined> => {
121 const dir = await stateDir();
122 const files = (await $.fs.list(dir)).filter(
123 (f) => f.kind === "file" && /^channel-relay-.*\.json$/.test(f.name),
124 );
125 const ports: RelayPortFile[] = [];
126 for (const f of files) {
127 try {
128 ports.push(JSON.parse(await $.fs.read(`${dir}/${f.name}`)));
129 } catch {
130 // a port file mid-write or already gone; next pass reads it
131 }
132 }
133 return pickRelay(ports, await $.session.id());
134 };
135
136 // ── inbound: pull the relay's inbox ──────────────────────────────────
137
138 let connected: string | undefined;
139 let failures = 0;
140 let superseded = 0;
141 const drop = async (why: string) => {
142 if (connected) $.ui.log(`bridge: relay lost (${why})`, { to: "debug" });
143 connected = undefined;
144 $.ui.status(undefined);
145 };
146
147 /** One poll; resolves the delay before the next, or undefined to stop. */
148 const tick = async (): Promise<number | undefined> => {
149 const relay = await findRelay().catch(() => undefined);
150 if (relay?.channelsEnabled) {
151 await patch($, { relay: "channels" });
152 return CHANNELS_RECHECK_MS;
153 }
154 if (!relay?.pullSocket) {
155 await drop("no relay for this session");
156 await patch($, { relay: "none" });
157 return RETRY_MS;
158 }
159 try {
160 const t0 = await $.clock.now();
161 const res = await $.http.fetch(
162 `http://relay/inbox?timeout=${POLL_MS}`,
163 { socketPath: relay.pullSocket },
164 );
165 if (!res.ok) throw new Error(`HTTP ${res.status}`);
166 if (connected !== relay.pullSocket) {
167 connected = relay.pullSocket;
168 $.ui.status("📡 telegram");
169 $.ui.log(`bridge: polling ${connected}`, { to: "debug" });
170 }
171 failures = 0;
172 const { messages } = JSON.parse(res.text) as { messages: Inbound[] };
173 // Submit first: the inbox has handed them over, nothing below may lose them.
174 for (const m of messages) {
175 void $.prompt
176 .submit({ text: toChannelPrompt(m), asUser: true })
177 .catch((err) => $.ui.log(`bridge: submit failed: ${err}`));
178 }
179 const now = await $.clock.now();
180 superseded =
181 !messages.length && now - t0 < SUPERSEDED_MS ? superseded + 1 : 0;
182 // one early return can be a reconnect; two in a row is a second poller
183 await patch($, {
184 relay: superseded >= 2 ? "contended" : "polling",
185 ...(messages.length ? { lastPhoneAt: now } : {}),
186 });
187 if (messages.length)
188 await update($, feed, (list) => addToFeed(list, messages, now)).catch(
189 (err) => $.ui.log(`bridge: feed: ${err}`, { to: "debug" }),
190 );
191 return superseded ? RETRY_MS : 0;
192 } catch (err) {
193 await drop(String(err));
194 if (++failures >= ERRORS_BEFORE_WARN)
195 await patch($, { relay: "error" });
196 return RETRY_MS;
197 }
198 };
199
200 // A timer chain, not a loop: the engine drops a module's timers when it
201 // reloads, so a reloaded copy never polls alongside the old one.
202 const schedule = (ms: number): void => {
203 $.clock.after(ms, () => {
204 void tick().then(
205 (delay) => {
206 if (delay !== undefined) schedule(delay);
207 },
208 (err) => $.ui.log(`bridge: stopped: ${err}`),
209 );
210 });
211 };
212 schedule(0);
213
214 // ── bot status, for the band ─────────────────────────────────────────
215
216 const refresh = async () => {
217 const known = await askBot<{ topic: TopicView | null }>(
218 `/session/${await $.session.id()}`,
219 );
220 const bot = await askBot<{ pid: number }>("/status");
221 await patch($, {
222 bot: bot ? "up" : "down",
223 botPid: bot?.pid ?? null,
224 topic: known?.topic
225 ? {
226 name: known.topic.name,
227 ...(known.topic.link ? { link: known.topic.link } : {}),
228 }
229 : null,
230 });
231 };
232 /** The bot's answer, or null when it's down or doesn't answer within BOT_TIMEOUT_MS. */
233 const askBot = async <T,>(path: string): Promise<T | null> => {
234 const ask = (async () => {
235 try {
236 const res = await $.http.fetch(`http://bot${path}`, {
237 socketPath: `${await stateDir()}/bot.sock`,
238 });
239 return res.ok ? (JSON.parse(res.text) as T) : null;
240 } catch {
241 return null;
242 }
243 })();
244 const timeout = new Promise<null>((resolve) => {
245 $.clock.after(BOT_TIMEOUT_MS, () => resolve(null));
246 });
247 return Promise.race([ask, timeout]);
248 };
249 refreshNow = refresh;
250 void refresh();
251 $.clock.every(STATUS_EVERY_MS, () => void refresh());
252
253 // ── /bridge doctor's I/O ─────────────────────────────────────────────
254
255 const doctor = async () => {
256 const sessionId = await $.session.id();
257 const [bot, known, relay] = await Promise.all([
258 askBot<{ pid: number; cwd?: string | null }>("/status"),
259 askBot<{ session: { name: string } | null; topic: TopicView | null }>(
260 `/session/${sessionId}`,
261 ),
262 findRelay().catch(() => undefined),
263 ]);
264 let botPids = await $.process.run(["ps", "-axo", "pid=,command="]).then(
265 (r) => botPidsFrom(r.stdout),
266 () => null,
267 );
268 // Other bun apps run src/index.ts too: keep those in the bot's folder.
269 if (botPids?.length && bot?.cwd) {
270 const cwd = bot.cwd;
271 botPids = await $.process
272 .run(["lsof", "-a", "-d", "cwd", "-Fn", "-p", botPids.join(",")])
273 .then(
274 (r) => pidsInCwd(r.stdout, cwd),
275 () => null,
276 );
277 }
278 // merged over user, project and local settings, as the engine reads them
279 const enabled = await $.settings.read().then(
280 (s) => (s.enabledPlugins ?? {}) as Record<string, unknown>,
281 () => null,
282 );
283 const logDir =
284 (await $.env.get("CLAUDE_TELEGRAM_LOG_DIR")) ??
285 `${await home()}/Library/Logs/claude-mobile-bridge`;
286 const recentErrors = await $.process
287 .run(["tail", "-n", "400", `${logDir}/bot.log`])
288 .then(
289 (r) =>
290 r.exitCode === 0
291 ? r.stdout
292 .split("\n")
293 .filter((l) => l.includes("[ERROR]"))
294 .slice(-3)
295 : null,
296 () => null,
297 );
298 return formatDoctor({
299 bot,
300 botPids,
301 sessionId,
302 known,
303 relay,
304 relayState: (await read($, status)).relay,
305 enabledLegacy: enabled
306 ? LEGACY_PLUGINS.filter((p) => enabled[p] === true)
307 : [],
308 formatEnabled: enabled ? enabled[FORMAT_PLUGIN] === true : null,
309 recentErrors,
310 });
311 };
312 let inFlight: Promise<string> | undefined;
313 /** One run at a time: a second press or `/bridge doctor` shares it. */
314 runDoctor = () =>
315 (inFlight ??= (async () => {
316 try {
317 await update($, doctorRun, (r) => ({ ...r, isRunning: true }));
318 const text = await doctor().catch(
319 (err) => `Bridge doctor failed: ${err}`,
320 );
321 const now = await $.clock.now();
322 await update($, doctorRun, () => ({
323 at: now,
324 text,
325 isRunning: false,
326 }));
327 return text;
328 } finally {
329 inFlight = undefined;
330 await update($, doctorRun, (r) =>
331 r.isRunning ? { ...r, isRunning: false } : r,
332 ).catch(() => {});
333 }
334 })());
335
336 return started;
337 });
338
339 on("command.run", { command: "bridge" }, async ($, e) => {
340 const arg = e.args.trim();
341 if (arg === "doctor") {
342 return {
343 text: runDoctor
344 ? await runDoctor()
345 : "Bridge doctor: still starting, try again.",
346 };
347 }
348 if (arg && arg !== "status")
349 return { text: "Usage: /bridge [status|doctor]" };
350 return { text: formatStatus(await read($, status), await $.clock.now()) };
351 });
352
353 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
354 if (e.props.hasSurvey) return next(e);
355 const now = await $.clock.now();
356 const s = await read($, status);
357 const isOpen = await read($, expanded);
358 const warning = bandFor(s, now);
359 // The desktop app has no status line to glance at: the band stays, one
360 // line, there. Elsewhere it shows only when it has something to say.
361 if (!warning && !isOpen && e.surface !== "desktop") return next(e);
362 const { Box, Text, Button, Link } = $.ui.resolve(e);
363 const health = bridgeHealth(s, now);
364 const toggle = (
365 <Button
366 key="details"
367 plain
368 dimColor
369 onPress={() => void update($, expanded, (v) => !v)}
370 >
371 {isOpen ? "hide ▴" : "details ▾"}
372 </Button>
373 );
374 const summary =
375 warning?.tone === "warning" ? (
376 <Text color="warning" wrap="wrap">
377 {warning.text}
378 </Text>
379 ) : (
380 <Text wrap="truncate">
381 <Text color={health.tone}>●</Text>
382 <Text dimColor>
383 {" "}
384 bridge · {health.text}
385 {s.topic ? ` · ${s.topic.name}` : ""}
386 {s.lastPhoneAt !== null ? ` · 📱 ${ago(now - s.lastPhoneAt)}` : ""}
387 </Text>
388 </Text>
389 );
390 const header = (
391 <Box flexDirection="row" gap={1} justifyContent="space-between">
392 <Box flexShrink={1} flexGrow={1}>
393 {summary}
394 </Box>
395 {toggle}
396 </Box>
397 );
398 if (!isOpen) return header;
399
400 const phone = (await read($, feed)).slice(0, BAND_FEED_ROWS);
401 const doc = await read($, doctorRun);
402 const rows = statusRows(s, now).map(
403 ([k, v]) => [k, k === "topic" && s.topic ? s.topic.name : v] as const,
404 );
405 const doctor = doctorLines(doc.text);
406 return (
407 <Box flexDirection="column">
408 {header}
409 <Box flexDirection="column">
410 {rows.map(([k, v]) => (
411 <Box key={`row-${k}`} flexDirection="row" gap={1}>
412 <Text dimColor mono>
413 {k.padEnd(5)}
414 </Text>
415 <Text wrap="wrap">{v}</Text>
416 </Box>
417 ))}
418 </Box>
419 <Box flexDirection="row" gap={1} flexWrap="wrap">
420 {s.topic?.link ? (
421 <Link href={s.topic.link} label="Open topic" />
422 ) : null}
423 <Button
424 key="doctor"
425 variant="primary"
426 onPress={() =>
427 void runDoctor?.().then((t) => $.ui.toast(doctorSummary(t)))
428 }
429 >
430 {doc.isRunning ? "Running…" : "Run doctor"}
431 </Button>
432 <Button key="refresh" onPress={() => void refreshNow?.()}>
433 Refresh
434 </Button>
435 </Box>
436 {doctor.length ? (
437 <Box flexDirection="column">
438 <Text dimColor weight="semibold">
439 Doctor · {doc.at ? clockTime(doc.at) : ""}
440 </Text>
441 {doctor.map((l) => {
442 const tone = doctorTone(l);
443 return (
444 <Text wrap="wrap" color={tone} dimColor={!tone}>
445 {tone ? l.trim() : ` ${l.trim()}`}
446 </Text>
447 );
448 })}
449 </Box>
450 ) : null}
451 <Box flexDirection="column">
452 <Text dimColor weight="semibold">
453 From phone
454 </Text>
455 {phone.length ? (
456 phone.map((m) => (
457 <Text wrap="truncate">
458 <Text dimColor mono>
459 {clockTime(m.at)}
460 </Text>{" "}
461 {m.text}
462 </Text>
463 ))
464 ) : (
465 <Text dimColor>nothing yet</Text>
466 )}
467 </Box>
468 </Box>
469 );
470 });
471};
472hooks/core.ts 365 lines1import type { BridgeStatus, FeedEntry } from "../types";
2
3/**
4 * Pure pieces of the bridge mod: the `<channel>` prompt, port-file
5 * choice, and what the band, `/bridge status` and `/bridge doctor` say.
6 * register.tsx does the I/O.
7 */
8
9export type RelayPortFile = {
10 sessionId?: string;
11 pullSocket?: string;
12 channelsEnabled?: boolean;
13 startedAt?: string;
14 pid?: number;
15};
16
17export type Inbound = { content: string; meta: Record<string, string> };
18
19export type TopicView = { name: string; link?: string };
20
21/** The companion that shows relayed prompts as `📱 <message>` (see plugins/bridge-format). */
22export const FORMAT_PLUGIN = "bridge-format@claude-mobile-bridge";
23
24/** Plugins this one replaces; both enabled means two pollers fight over one inbox. */
25export const LEGACY_PLUGINS = [
26 "channel-pull@claude-mobile-bridge",
27 "channel-pull-format@claude-mobile-bridge",
28];
29
30const escapeAttr = (v: string) =>
31 v.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
32
33/**
34 * The same `<channel>` tag a channels-enabled session sees. A `<channel` or
35 * `</channel` inside the message is escaped so the text can't close the tag
36 * and forge a second one (another `user=`).
37 */
38export const toChannelPrompt = (m: Inbound): string => {
39 // `source` first and fixed: bridge-format's split expects it, and meta can't override it.
40 const { source: _source, ...meta } = m.meta;
41 const attrs = Object.entries({ source: "channel-relay", ...meta })
42 .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`)
43 .join(" ");
44 const body = m.content.replace(/<(\/?channel)/gi, "<$1");
45 return `<channel ${attrs}>\n${body}\n</channel>`;
46};
47
48/** This session's relay: the newest port file naming its id (a SIGKILLed relay leaves a stale one). */
49export const pickRelay = (
50 ports: RelayPortFile[],
51 sessionId: string,
52): RelayPortFile | undefined =>
53 ports
54 .filter((p) => p.sessionId === sessionId)
55 .sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""))[0];
56
57/** No warning about a missing relay for this long after start: it's still coming up. */
58export const STARTUP_GRACE_MS = 30_000;
59/** The band keeps its status line up (on the terminal too) this long after a phone message. */
60export const RECENT_PHONE_MS = 15 * 60_000;
61
62export const ago = (ms: number): string => {
63 const m = Math.floor(ms / 60_000);
64 return m < 1
65 ? "just now"
66 : m < 60
67 ? `${m}m ago`
68 : `${Math.round(m / 60)}h ago`;
69};
70
71/**
72 * Why the band shows, or undefined when there's nothing worth showing. A
73 * warning's text is drawn; a subtle one (a recent phone message) only keeps
74 * the status line up — its text is for callers that want a sentence.
75 */
76export const bandFor = (
77 s: BridgeStatus,
78 now: number,
79): { tone: "warning" | "subtle"; text: string } | undefined => {
80 const settled = now - s.startedAt >= STARTUP_GRACE_MS;
81 if (s.bot === "down") {
82 return {
83 tone: "warning",
84 text: "⚠️ bridge bot not answering — phone messages wait in Telegram until it's back · /bridge doctor",
85 };
86 }
87 if (s.relay === "contended") {
88 return {
89 tone: "warning",
90 text: "⚠️ two pollers on this session's relay (old channel-pull, or bridge loaded twice) · /bridge doctor",
91 };
92 }
93 if (s.relay === "none" && settled) {
94 return {
95 tone: "warning",
96 text: "⚠️ no channel-relay for this session — the phone can't reach it · /bridge doctor",
97 };
98 }
99 if (s.relay === "error") {
100 return {
101 tone: "warning",
102 text: "⚠️ channel-relay not answering — retrying · /bridge doctor",
103 };
104 }
105 if (s.lastPhoneAt !== null && now - s.lastPhoneAt < RECENT_PHONE_MS) {
106 const where = s.topic ? ` in ${s.topic.name}` : "";
107 return {
108 tone: "subtle",
109 text: `📱 phone message${where} · ${ago(now - s.lastPhoneAt)}`,
110 };
111 }
112 return undefined;
113};
114
115/** `/bridge status`'s rows (label, text), shared with the band's details. */
116export const statusRows = (
117 s: BridgeStatus,
118 now: number,
119): [label: "bot" | "topic" | "relay" | "phone", text: string][] => {
120 const bot =
121 s.bot === "up"
122 ? `up${s.botPid ? ` (pid ${s.botPid})` : ""}`
123 : s.bot === "down"
124 ? "down — no answer on bot.sock"
125 : "checking…";
126 const relay = {
127 polling: "polling (desktop pull inbox)",
128 contended:
129 "polling, but another poller shares the inbox (old channel-pull, or bridge loaded twice)",
130 channels: "channels on — relay notifies directly",
131 none: "none found for this session",
132 error: "found but not answering — retrying every 5s",
133 unknown: "checking…",
134 }[s.relay];
135 const topic = s.topic
136 ? `${s.topic.name}${s.topic.link ? ` — ${s.topic.link}` : ""}`
137 : "none yet";
138 const phone =
139 s.lastPhoneAt === null ? "none here yet" : ago(now - s.lastPhoneAt);
140 return [
141 ["bot", bot],
142 ["topic", topic],
143 ["relay", relay],
144 ["phone", `last message ${phone}`],
145 ];
146};
147
148/** `/bridge status`. */
149export const formatStatus = (s: BridgeStatus, now: number): string =>
150 [
151 "Bridge status",
152 ...statusRows(s, now).map(([k, v]) => ` ${`${k}:`.padEnd(7)} ${v}`),
153 ].join("\n");
154
155/** The band's headline: a few words and their color. */
156export const bridgeHealth = (
157 s: BridgeStatus,
158 now: number,
159): { tone: "success" | "warning" | "error" | "subtle"; text: string } => {
160 const settled = now - s.startedAt >= STARTUP_GRACE_MS;
161 if (s.bot === "down") return { tone: "error", text: "bot down" };
162 if (s.relay === "error" || (s.relay === "none" && settled))
163 return { tone: "error", text: "phone can't reach this session" };
164 if (s.relay === "contended")
165 return { tone: "warning", text: "two pollers on the relay" };
166 if (s.bot === "unknown" || s.relay === "unknown" || s.relay === "none")
167 return { tone: "subtle", text: "checking…" };
168 return { tone: "success", text: "connected" };
169};
170
171/** Longest message text the feed keeps. */
172export const FEED_TEXT_MAX = 140;
173/** Messages the feed keeps. */
174export const FEED_MAX = 10;
175
176/** A relayed message as the band's details list it: its first line, cut short. */
177export const toFeedEntry = (m: Inbound, at: number): FeedEntry => {
178 // by code point, so an emoji at the cut isn't split in half
179 const line = [...(m.content.trim().split("\n")[0] ?? "")];
180 return {
181 at,
182 user: m.meta.user ?? null,
183 text:
184 line.length > FEED_TEXT_MAX
185 ? `${line.slice(0, FEED_TEXT_MAX - 1).join("")}…`
186 : line.join("") || "(attachment)",
187 };
188};
189
190/** The feed with one poll's messages (oldest first, as the relay queues them) added: newest first, capped. */
191export const addToFeed = (
192 list: FeedEntry[],
193 messages: Inbound[],
194 at: number,
195): FeedEntry[] =>
196 [...messages.map((m) => toFeedEntry(m, at)).reverse(), ...list].slice(
197 0,
198 FEED_MAX,
199 );
200
201/** A doctor run's lines for the band: without the header, or the whole text when it failed. */
202export const doctorLines = (text: string | null): string[] => {
203 if (!text) return [];
204 const lines = text.split("\n");
205 return lines[0] === "Bridge doctor" ? lines.slice(1) : lines;
206};
207
208/** HH:MM, local time. */
209export const clockTime = (ms: number): string => {
210 const d = new Date(ms);
211 return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
212};
213
214/** One line for a toast: how a doctor run came out. */
215export const doctorSummary = (text: string): string => {
216 const tones = doctorLines(text).map(doctorTone);
217 const n = (t: string) => tones.filter((x) => x === t).length;
218 if (!tones.some(Boolean)) return text.split("\n")[0] ?? "Bridge doctor";
219 const parts = [
220 n("error") && `${n("error")} ❌`,
221 n("warning") && `${n("warning")} ⚠️`,
222 n("success") && `${n("success")} ✅`,
223 ].filter(Boolean);
224 return `Bridge doctor: ${parts.join(" · ")}`;
225};
226
227/** A doctor line's color in the band, by its mark. */
228export const doctorTone = (
229 line: string,
230): "success" | "warning" | "error" | "subtle" | undefined => {
231 const l = line.trimStart();
232 if (l.startsWith("⏳")) return "subtle";
233 return l.startsWith("❌") || l.startsWith("Bridge doctor failed")
234 ? "error"
235 : l.startsWith("⚠️")
236 ? "warning"
237 : l.startsWith("✅")
238 ? "success"
239 : undefined;
240};
241
242/**
243 * Candidate bot pids from `ps -axo pid=,command=`: bun running `src/index.ts`.
244 * Any bun app can match; narrow with `pidsInCwd` once the bot's cwd is known.
245 */
246export const botPidsFrom = (psOut: string): number[] =>
247 psOut
248 .split("\n")
249 .map((l) => /^\s*(\d+)\s+(.*)$/.exec(l))
250 .filter((m): m is RegExpExecArray => !!m)
251 .filter(
252 ([, , cmd]) => /\bbun\b/.test(cmd!) && /\bsrc\/index\.ts\b/.test(cmd!),
253 )
254 .map(([, pid]) => Number(pid));
255
256/** Pids whose cwd is `cwd`, from `lsof -a -d cwd -Fn -p <pids>` (`p<pid>` / `n<path>` lines). */
257export const pidsInCwd = (lsofOut: string, cwd: string): number[] => {
258 const out: number[] = [];
259 let pid: number | undefined;
260 for (const l of lsofOut.split("\n")) {
261 if (l.startsWith("p")) pid = Number(l.slice(1));
262 else if (l.startsWith("n") && l.slice(1) === cwd && pid) out.push(pid);
263 }
264 return out;
265};
266
267export type DoctorFacts = {
268 /** The bot's /status, or null when the socket didn't answer. */
269 bot: { pid: number } | null;
270 /**
271 * Bot pids from ps; null when ps couldn't run. With the bot up, only those
272 * in its cwd; with it down, every `bun … src/index.ts` (maybe not ours).
273 */
274 botPids: number[] | null;
275 sessionId: string;
276 /** The bot's /session/<id>; null when the bot didn't answer. */
277 known: { session: { name: string } | null; topic: TopicView | null } | null;
278 relay: RelayPortFile | undefined;
279 relayState: BridgeStatus["relay"];
280 enabledLegacy: string[];
281 /** bridge-format enabled in settings; null when settings couldn't be read. */
282 formatEnabled: boolean | null;
283 /** `[ERROR]` lines near the end of bot.log; null when it couldn't be read. */
284 recentErrors: string[] | null;
285};
286
287/** `/bridge doctor`: one ✅/⚠️/❌ line per check, with what to do. */
288export const formatDoctor = (f: DoctorFacts): string => {
289 const out: string[] = ["Bridge doctor"];
290 const ok = (t: string) => out.push(` ✅ ${t}`);
291 const warn = (t: string) => out.push(` ⚠️ ${t}`);
292 const bad = (t: string) => out.push(` ❌ ${t}`);
293
294 if (f.bot) {
295 ok(`bot answers on bot.sock (pid ${f.bot.pid})`);
296 const others = (f.botPids ?? []).filter((p) => p !== f.bot!.pid);
297 if (others.length)
298 warn(
299 `${others.length + 1} bot processes in the bot's folder — the live one is ${f.bot.pid}; stop the others: kill ${others.join(" ")}`,
300 );
301 else if (f.botPids) ok("one bot process");
302 } else if (f.botPids?.length) {
303 bad(
304 `bot.sock doesn't answer, but \`bun … src/index.ts\` is running (pid ${f.botPids.join(", ")}) — if that's the bot, it predates the control socket: git pull and restart it inside cmux`,
305 );
306 } else {
307 bad(
308 "bot doesn't answer on ~/.claude-mobile-bridge/bot.sock and no bot process is running — start `bun run dev` inside cmux",
309 );
310 }
311
312 if (f.known) {
313 if (f.known.session)
314 ok(`bot tracks this session as "${f.known.session.name}"`);
315 else
316 bad(
317 `bot doesn't track this session (${f.sessionId}) — its watcher can't see it; look for "session found" in bot.log`,
318 );
319 if (f.known.topic)
320 ok(
321 `topic "${f.known.topic.name}"${f.known.topic.link ? ` — ${f.known.topic.link}` : ""}`,
322 );
323 else warn("no Telegram topic for this session yet");
324 }
325
326 if (!f.relay)
327 bad(
328 "no channel-relay port file names this session — is channel-relay registered as a user MCP server?",
329 );
330 else if (f.relay.channelsEnabled)
331 ok("channel-relay has channels on (CLI session) — no pull needed");
332 else if (!f.relay.pullSocket)
333 bad(
334 "channel-relay offers no pull socket — update the relay (pull it from main)",
335 );
336 else if (f.relayState === "polling") ok(`polling ${f.relay.pullSocket}`);
337 else if (f.relayState === "contended")
338 warn(
339 `another poller shares ${f.relay.pullSocket} — uninstall channel-pull, or drop the extra bridge copy (a --plugin-dir / dev folder)`,
340 );
341 else if (f.relayState === "unknown")
342 // just started or reloaded: the first long-poll hasn't answered yet
343 out.push(` ⏳ found ${f.relay.pullSocket} — first poll still in flight`);
344 else warn(`not polling ${f.relay.pullSocket} yet — retrying every 5s`);
345
346 if (f.enabledLegacy.length)
347 warn(
348 `${f.enabledLegacy.join(", ")} also enabled — two pollers fight over the inbox; run: ` +
349 f.enabledLegacy.map((p) => `claude plugin uninstall ${p}`).join(" && "),
350 );
351
352 if (f.formatEnabled === false)
353 warn(
354 `bridge-format isn't enabled — relayed prompts show their raw <channel> tag; run: claude plugin install ${FORMAT_PLUGIN}`,
355 );
356
357 if (f.recentErrors === null) warn("couldn't read bot.log");
358 else if (f.recentErrors.length) {
359 warn(`recent errors in bot.log (last ${f.recentErrors.length}):`);
360 for (const e of f.recentErrors) out.push(` ${e.slice(0, 200)}`);
361 } else ok("no recent errors in bot.log");
362
363 return out.join("\n");
364};
365types/index.d.ts 48 lines1/** What the band and `/bridge status` show; written by the poll and status timers. */
2export type BridgeStatus = {
3 /** The bot's control socket answered on the last check. */
4 bot: "up" | "down" | "unknown";
5 botPid: number | null;
6 /**
7 * polling: pulling from the relay · contended: another poller shares the
8 * inbox · channels: CLI session, relay notifies · none: no relay found ·
9 * error: relay found but not answering
10 */
11 relay: "polling" | "contended" | "channels" | "none" | "error" | "unknown";
12 /** This session's Telegram topic, as the bot maps it. */
13 topic: { name: string; link?: string } | null;
14 /** When the last phone message was submitted here (epoch ms). */
15 lastPhoneAt: number | null;
16 /** When this copy of the mod started (epoch ms) — grace for startup warnings. */
17 startedAt: number;
18};
19
20/** One relayed phone message, as the band's details list it. */
21export type FeedEntry = {
22 /** When it was submitted here (epoch ms). */
23 at: number;
24 user: string | null;
25 /** First line, cut to fit a row. */
26 text: string;
27};
28
29/** The last `/bridge doctor` run, shown in the band's details. */
30export type DoctorRun = {
31 at: number | null;
32 /** formatDoctor's text; null before the first run. */
33 text: string | null;
34 isRunning: boolean;
35};
36
37declare module "claude-code" {
38 interface PluginState {
39 bridge: {
40 status: BridgeStatus;
41 feed: FeedEntry[];
42 doctor: DoctorRun;
43 /** The band's details are shown. */
44 expanded: boolean;
45 };
46 }
47}
48