SLOPSHOPPER

bridge

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

newbandcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bridge
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /bridge ⎿ bridge: Bridge status ⎿ bridge: bot: down — no answer on bot.sock ⎿ bridge: topic: none yet ⎿ bridge: relay: checking… ⎿ bridge: phone: last message none here yet ⚠️ bridge bot not answering — phone messages wait in Telegram until it's back · /bridge doctor details ▾ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚠️ bridge bot not answering — phone messages wait in Telegram until it's back · /bridge doctor detail
README

bridge

claude-mobile-bridge inside the Claude desktop app (Code tab):

  • Telegram in: messages from a session's Telegram topic arrive as prompts (desktop sessions can't load channels, so the channel relay can't push them).
  • Status band above the prompt, with warnings when they matter: ⚠️ 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.
  • Details in the band: in the desktop app the band always shows one line (● 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.

Install

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

How

Telegram ─► bot ─TCP─► channel-relay ─unix socket─► bridge ─► $.prompt.submit ─► bridge-format (📱)
                bot.sock (status) ◄────────────────── bridge (band, /bridge)
  • Inbound. The relay checks its parent 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.
  • Status. Every 15s the mod asks the bot's control socket, ~/.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).

Not covered on desktop

  • Restarting the bot: it has to run inside cmux, which a desktop session can't reach. The band and /bridge doctor say when it's down.
  • Remote tool-permission cards (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.
  • Anything that types into a terminal (tmux, cmux, Cursor) can't reach 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.

Develop

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.

Source 3 files
hooks/register.tsx 472 lines
1import { 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};
472
hooks/core.ts 365 lines
1import 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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, "&lt;$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};
365
types/index.d.ts 48 lines
1/** 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