feat(remote-access): mobile companion — Telegram first (foundation)#1339
feat(remote-access): mobile companion — Telegram first (foundation)#1339Astro-Han wants to merge 38 commits into
Conversation
There was a problem hiding this comment.
Suggested priority: P2 (includes user-path files (packages/app/src/app.tsx, packages/app/src/components/dialog-connect-remote.tsx, packages/app/src/desktop-api-contract.ts, packages/app/src/desktop-api.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/pages/settings/remote.tsx, packages/desktop-electron/src/main/index.ts, packages/desktop-electron/src/main/ipc/remote.ts, packages/desktop-electron/src/main/remote-bridge.test.ts, packages/desktop-electron/src/main/remote-bridge.ts, packages/desktop-electron/src/main/remote-credentials.ts, packages/desktop-electron/src/preload/index.ts, packages/desktop-electron/src/preload/types.ts)).
P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds a Telegram remote bridge feature enabling a mobile device to send messages to the desktop agent. The change introduces shared ChangesTelegram Remote Bridge
Sequence Diagram(s)sequenceDiagram
participant User
participant RemotePage
participant DialogConnect
participant PreloadAPI
participant MainProcess
participant RemoteBridge
participant TelegramAPI
rect rgba(70, 130, 180, 0.5)
note over User,TelegramAPI: Connect Flow
User->>RemotePage: click Connect button
RemotePage->>DialogConnect: open via dynamic import
User->>DialogConnect: enter bot token
DialogConnect->>PreloadAPI: api.remote.startPairing(token)
PreloadAPI->>MainProcess: ipcRenderer.invoke("remote:start-pairing")
MainProcess->>RemoteBridge: startPairing(token)
RemoteBridge->>TelegramAPI: getMe(token) validation
RemoteBridge->>TelegramAPI: getUpdates (drain backlog)
TelegramAPI-->>RemoteBridge: first new private message
RemoteBridge-->>DialogConnect: RemotePairingResult (sender info)
DialogConnect->>User: show "Allow" confirmation
User->>DialogConnect: click Allow
DialogConnect->>PreloadAPI: api.remote.confirmPairing()
PreloadAPI->>MainProcess: ipcRenderer.invoke("remote:confirm-pairing")
MainProcess->>RemoteBridge: confirmPairing()
RemoteBridge->>RemoteBridge: persist credentials via safeStorage
RemoteBridge->>TelegramAPI: start polling bridge
RemoteBridge->>MainProcess: onStatusChange fired
MainProcess->>RemotePage: send("remote:status", connected)
RemotePage->>User: show connected status icon
end
rect rgba(180, 70, 70, 0.5)
note over User,TelegramAPI: Disconnect Flow
User->>RemotePage: click Disconnect
RemotePage->>DialogConnect: open disconnect dialog
User->>DialogConnect: confirm
DialogConnect->>PreloadAPI: api.remote.disconnect()
PreloadAPI->>MainProcess: ipcRenderer.invoke("remote:disconnect")
MainProcess->>RemoteBridge: disconnect()
RemoteBridge->>RemoteBridge: stop bridge, clear credentials
RemoteBridge->>MainProcess: onStatusChange fired
MainProcess->>RemotePage: send("remote:status", disconnected)
RemotePage->>User: show disconnected state
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~80 minutes Possibly related PRs
Suggested labels
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Code Review
This pull request implements a mobile-companion remote bridge that allows users to securely connect and message their desktop agent via Telegram. It introduces the frontend pairing and connection UI, localized strings, IPC communication, secure credential storage using Electron's safeStorage, and a background Telegram polling runtime. The review feedback highlights three key issues: first, the userName is lost upon reloading saved credentials, causing the display name to revert to a raw ID; second, the pairing backlog is only drained once, which can fail if there are more than 100 pending messages; and third, a non-numeric update_id can propagate NaN to the polling offset, permanently breaking subsequent requests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/app/src/components/dialog-connect-remote.tsx`:
- Around line 46-50: The startPairing() call can resolve after the user cancels
the dialog, causing the store to update to the "confirm" step despite the
cancellation. Implement a pairing-attempt identifier (such as an incremented ID
or unique token) that is created when startPairing() begins. When the user
cancels, increment or invalidate this identifier. After the startPairing()
promise resolves, verify that the pairing-attempt identifier still matches the
current one before updating the store with setStore(). This ensures stale
completions from earlier pairing attempts are ignored and won't overwrite the
user's cancellation action. Apply this same pattern to the other location in the
file where a similar async result is handled.
In `@packages/app/src/i18n/en.ts`:
- Around line 968-983: The interpolation tokens in the translation strings are
using single braces instead of double braces, which will cause runtime values to
not be substituted correctly. In the en.ts file, update the following string
tokens: change {name} to {{name}} in the "settings.remote.pairedWith" string and
the "settings.remote.connect.confirm.body" string, and change {error} to
{{error}} in the "settings.remote.connect.error" string. This ensures that the
i18n interpolation system will properly substitute the actual values at runtime
instead of displaying raw placeholder text.
In `@packages/app/src/pages/settings/remote.tsx`:
- Around line 35-45: The statusLabel function in remote.tsx uses a default case
instead of explicitly handling all status states, causing an exhaustive-switch
lint error. Replace the default case with an explicit "disconnected" case that
returns the same language.t("settings.remote.status.disconnected") translation.
This ensures the switch statement covers all possible status states explicitly,
allowing the linter to properly type-check any future additions to the status
state type.
In `@packages/desktop-electron/src/main/remote-bridge.ts`:
- Around line 120-129: In the try-catch block where poller.getMe is called, the
catch handler currently always throws a generic "could not reach Telegram with
that token" error. You need to differentiate between an aborted signal (which
occurs when cancelPairing() is called) and other errors. Check if the caught
error is an AbortError or if the signal ac.signal has been aborted, and if so,
throw PairingCancelledError instead of the token error message. For non-abort
errors, keep the existing "could not reach Telegram with that token" error
message.
In `@packages/desktop-electron/src/main/remote-credentials.ts`:
- Around line 47-50: The load() method in the RemoteCredentials class is
dropping the userName field that is persisted by save(), causing identity
information to be lost on restart. Modify the return statement in the load()
method to include the userName field along with token and allowFrom. Update the
validation check on line 49 to verify that userName exists alongside token and
allowFrom, then include userName in the returned object on line 50 to preserve
the complete credential data across application relaunches.
In `@packages/remote-bridge/src/platforms/telegram.ts`:
- Around line 354-378: The captureFirstSender function has two paths without
retry logic for transient errors: the initial backlog poll and the final ack
call. The main polling loop (lines 361-370) already implements retry logic using
isFatalTelegramError to distinguish transient from fatal errors, but the backlog
fetch and ack skip this protection. Extract the retry logic from the main loop
into a helper function (e.g., getUpdatesWithRetry) that accepts the offset,
signal, and optional timeout parameter, handles the try-catch with
isFatalTelegramError checks, sleeps on transient errors using the same backoff
calculation, and returns the updates or retries. Then replace the standalone
backlog poll call and the ack call with invocations of this helper to ensure
both paths benefit from consistent transient error handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 90ab82b8-60ca-43ff-a527-badfe77cf0e2
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (19)
packages/app/src/app.tsxpackages/app/src/components/dialog-connect-remote.tsxpackages/app/src/desktop-api-contract.tspackages/app/src/desktop-api.tspackages/app/src/i18n/en.tspackages/app/src/i18n/zh.tspackages/app/src/pages/settings/remote.tsxpackages/desktop-electron/package.jsonpackages/desktop-electron/src/main/index.tspackages/desktop-electron/src/main/ipc/remote.tspackages/desktop-electron/src/main/remote-bridge.test.tspackages/desktop-electron/src/main/remote-bridge.tspackages/desktop-electron/src/main/remote-credentials.tspackages/desktop-electron/src/preload/index.tspackages/desktop-electron/src/preload/types.tspackages/desktop-electron/tsconfig.jsonpackages/remote-bridge/src/platforms/README.mdpackages/remote-bridge/src/platforms/telegram.test.tspackages/remote-bridge/src/platforms/telegram.ts
…tsApp official needs relay
…pturing a sender A single immediate getUpdates returns at most one batch (~100 updates); a larger pre-pairing backlog left the rest, and the first long-poll would then hand back a stale message as the newly-approved sender. Loop the immediate drain until empty.
…rnalizing its TS source remote-bridge exports only .ts source; externalized, the built out/main kept a bare import that resolves to .ts at runtime (Electron can't run it) and trips the runtime-import guard. Exclude it from externalizeDeps so Vite inlines it.
…entials load() dropped userName, so after a restart the settings page showed the raw user id instead of the approved display name.
…nding it startPairing now stashes the token main-side; confirmPairing() takes no args and approves the captured identity, so the renderer never resends the secret and can't supply or swap a token at confirm time.
List the "disconnected" case explicitly instead of relying on default; @typescript-eslint/switch-exhaustiveness-check does not treat default as covering a union member.
The P1a fix added exclude: ["@opencode-ai/remote-bridge"] to externalizeDeps; assert node-pty stays externalized and remote-bridge stays bundled.
…rministic test The store imported electron directly, so its unit test relied on a global electron module mock — order-dependent across test files (and it linked/evaluated real electron, which fails without an installed binary). Make the store Electron-free with an injected env; wire the safeStorage-backed env in index.ts. The test now uses a fake env, no electron mock.
The resolver (@solid-primitives/i18n) substitutes {{name}}, not {name}, so the
remote strings rendered the raw braces. Fix pairedWith and confirm.body in both
locales and drop the unused connect.error key. Add a resolver test over the real
strings.
The final ack used .catch(() => []), so a transient failure left the captured pairing message un-acked; the steady-state bridge (polling from offset 0) would then replay it as the user's first prompt. Extract getUpdatesWithRetry and use it for the drain, the wait, and the ack.
…cting it startPairing set pending unconditionally after capture; a sender arriving after cancelPairing (or a superseding startPairing) would revive an abandoned pairing. Throw PairingCancelledError if the attempt was aborted or superseded before storing pending.
splitForTelegram broke a long reply on a line boundary by slicing the chunk before the newline and then stripping a leading newline from the remainder, so the delimiter was dropped from both sides — reassembling the chunks did not equal the original text. Keep the newline as the chunk's last char and stop stripping the remainder, so every character lands in exactly one chunk. Test: a reply with a newline in the split window; reassembled bodies equal the original. remote-bridge: typecheck clean, 115 tests pass.
writeFileSync's mode argument only applies when the file is created; rewriting an existing credentials file kept its old permissions, so a token file ever left world-readable (older build, copy) would not be tightened on the next save. chmodSync the file to 0o600 after writing. No-op on Windows, which has no POSIX mode bits. Test (POSIX-only): pre-create the file 0o644, save(), assert final mode 0o600. desktop-electron: typecheck clean, 541 tests pass.
RemotePage rendered an active Connect button even when window.api.remote was absent (web preview, or a preload regression); clicking it opened a dialog that silently no-ops. Render a disabled control and a 'requires the desktop app' label when the preload API is missing, so the connect flow cannot be started into a dead end. The page is not yet wired into the settings surface, so there is no runtime route to it; verified by typecheck and inspection. Lint clean, app typecheck clean.
The connect dialog guarded late startPairing() results only by dialog lifetime (alive), not by pairing attempt. If the main process captured a sender and the IPC success was in flight at the moment the user cancelled (backToToken: step -> token), the resolved continuation still ran and set step -> confirm, reversing the UI after cancel. The main-side cancel guard can't prevent this because the main startPairing may already have resolved before the cancel IPC arrives. Add a monotonic attempt id, bumped on cancel/restart, and drop any resolution whose id is no longer current. The dialog is not yet wired into settings (no runtime route), so this is verified by typecheck/inspection; the render test belongs with the wiring-in change. Lint clean, app typecheck clean.
The remote access page and connect dialog existed but were unreachable: RemotePage was referenced nowhere and settings-shell deliberately omitted the tab. That omission was a leftover note from the settings rewrite (#975), which predates this work and is unrelated to it — not a decision to defer remote access. Per the one-PR-per-provider plan, landing the Telegram page means wiring it in: register the remoteAccess tab (type, TAB_VALUES, NAV_ITEMS with the remote-control icon, content Match) after Integrations, and drop the stale note. Add a settings-remote snap target that injects a window.api.remote stub (snap is web Chromium with no preload) to capture the disconnected page, the token step, and the confirm step. Lint + app typecheck clean.
Direction A (chosen): the Telegram row gains a vendor logo and a 2px status left-rule (green connected / red degraded / neutral idle, per the cards rule in docs/DESIGN.md), replacing the bare status line. Add a 'What you can do' capability list (send prompts, approve permissions, switch/stop sessions) using existing in-set glyphs (prompt / lock / new-session), and a footnote covering @Botfather pairing and encrypted-on-device token storage. Connect stays secondary for consistency with the other settings pages. Verified with the settings-remote snap (web stub) plus lint and app typecheck.
The @Botfather pairing note sat as a page-level footnote, but it is Telegram-specific and read as guidance for the whole Remote access page (and would be wrong once other providers land). Move it inside the Telegram block, under the same 2px status left-rule as the status row, so it clearly belongs to Telegram. 'What you can do' stays page-level. Lint + app typecheck clean; verified via the settings-remote snap.
The @Botfather pairing note was guidance for the connect flow, not a fact the settings page needs to show at rest. Remove it from the page (the Telegram block is back to a single status row + the page-level 'What you can do'). @Botfather and the first-sender rule already live in the dialog (token.help / waiting.body); fold the one missing point — the token is stored encrypted on this computer — into token.help, where the user pastes it. Drops the settings.remote.note key. Lint + app typecheck clean; verified via the settings-remote snap.
TelegramPlatform.start hardcoded runLoop(0), so after a restart Telegram would redeliver any still-unacked updates and the bridge would re-dispatch them — replaying a prompt re-drives the agent. Drain the backlog on (re)start (advance the offset past what is queued without dispatching) and poll forward from the live tip, so an offline-period prompt and a crash-interrupted batch are both dropped instead of replayed. This also subsumes message-id dedup: Telegram's offset ack already prevents in-process duplicates, and the drain closes the cross-restart window. Extracted the shared drain helper that pairing capture already used.
startBridge set status to 'connecting' then awaited serverInfo()/buildApp(). If either threw, the exception propagated with the status stuck on 'connecting'. startIfConfigured has its own catch, but confirmPairing awaits startBridge directly, so a failed confirm could hang the UI on connecting forever. Set degraded on any pre-run build failure (run() failures already become degraded), so every failure path ends on a terminal status.
The comments claimed the bot token 'never crosses the renderer IPC boundary', but the user pastes it once over remote:start-pairing. Correct them: the token crosses IPC exactly once inbound; from there it is main-only and never sent back (confirm carries no token, status is masked, the stored secret never returns). No behavior change.
The shell spec still asserted the Remote access tab was absent (hidden until ready), but this PR wired it in as a real entry — failing e2e-artifacts. Assert the new fact: 7 tabs including Remote access, and clicking it renders the Telegram connection row (Connect button + Telegram). Verified locally — 4 passed.
runLoop, drainBacklog, and captureFirstSender each hand-wrote the offset advance, and Number(update_id) on a malformed id poisoned the offset with NaN — every later getUpdates would then request offset=NaN (serialized to null) and replay the backlog. Extract nextOffset(offset, update): advance only on a finite id, else hold the offset. Added a malformed-update_id test.
before-quit runs `void stop(); killSidecar()` without awaiting, so the bridge's abort previously landed inside the queued stopBridge — possibly after the sidecar was already killed, leaving the poll loop running against a dead server. stop() now aborts the live bridge on its synchronous prefix, before the first await, so the abort precedes killSidecar. Added a test asserting the abort fires before stop()'s promise settles.
The snap stubbed only the disconnected page plus the token/confirm dialogs. Make the stubbed status mutable (driven via window.__remote) and park startPairing, so one run snapshots every state the user sees: the disconnected/connected/degraded pages and the token/waiting/confirm/disconnect dialogs.
Question/permission answers are typed text today. Capture the inline-keyboard / card optimization a later agent would pick up — the Platform/engine/telegram changes, the 64-byte callback_data trap, and the multi-select/multi-question staging — so the plan isn't lost. Deferred on purpose in favour of broadening platform support first.
Question/permission/command copy was hardcoded English. Add Config.locale driving an engine lookup table (en/zh) — plain tables + a tiny t(), no i18n framework for ~25 strings. Chinese uses the app's localized name 爪印 and drops the product name in command replies where chat context already makes the sender obvious. Clarity, in both languages: number multi-question prompts (Question N / 问题 N) so 'one answer per line' maps onto them; hints branch by single/multi-select and multi-question; the line-count error states the count. permissionReplyForText also accepts Chinese keywords (是/总是/否…) so a zh user never switches to English. Chinese copy polished by DeepSeek v4-pro. Locale is wired from the desktop next. Tests: i18n unit (t/normalizeLocale) + engine zh integration (numbering, hints, line-count error, Chinese reply keyword).
The bridge renders chat copy in a locale now; wire it from the desktop's current UI language (desktop-context-store) so a Chinese user gets Chinese prompts with no extra setting. gateway re-exports normalizeLocale; the runtime reads deps.locale() at bridge start and normalizes it into Config.locale (a snapshot — a language change takes effect on the next reconnect).
confirmPairing() saved the token, and the safeStorage availability check lived inside save() — so on a system without OS encryption the user walked the entire token → message bot → confirm flow only to fail at the final save. Expose isAvailable() on CredentialStore and check it at the top of startPairing(), before any Telegram contact. When encryption is unavailable pairing fails immediately and never starts a poller. Reported by code review (P2).
…irst message startPairing() ran getMe() first, then capture() — whose first step drains the backlog. The UI invites the user to message the bot the moment startPairing is pending, so a message sent during a slow getMe sat in the queue and was then swept up by that drain. Pairing waited forever for a second message. Fold verify + drain + capture into one pairing primitive: captureFirstSender now drains (pinning the offset baseline and proving the token) BEFORE fetching the bot identity, then long-polls for the first sender and returns botUsername too. A message arriving during getMe now lands past the baseline and is captured. Reported by code review (P2).
Review round — pairing robustnessP2 — pairing dropped the user's first message (fixed fd5d6d5): P2 — secure storage failed only at the final save (fixed 4eebe63): Gemini bot — 3 open threads were stale (reviewed an earlier revision), resolved with current-HEAD evidence: remote-bridge 21 pass · desktop remote 16 pass · typecheck clean. |
…serving startBridge reported "connected" the instant app.run() returned a Promise, but run() still has to ready the event stream, hydrate, and let TelegramPlatform.start drain the Telegram backlog before installing its live poll loop. A first prompt sent right after the success status arrived during that window and was discarded as backlog instead of delivered. Thread an onReady signal from the platform up through the gateway: TelegramPlatform fires it once the backlog drain is done and the poll loop is installed, App.run fires its onReady once every platform is serving, and startBridge flips to "connected" from that callback — staying "connecting" until then. A build/run failure still lands on degraded, so the status can't hang.
Review fix — startup race (
|
… 409s
A getUpdates 409 means another client owns the bot token, so the poll loop
receives nothing. start() previously fired onReady right after installing the
loop and runLoop retried 409 forever, so the desktop showed connected over a
token that silently delivered nothing.
- runLoop fires onReady only after the first getUpdates that actually returns,
and rejects with TelegramConflictError after MAX_CONFLICT_RETRIES 409s
(counter resets on any success). getUpdatesWithRetry (drain/pairing) bounds
409s the same way.
- start() hands onReady to runLoop instead of calling it on loop install.
A persistent 409 now keeps the bridge out of "ready" and rejects start(),
which the runtime surfaces as "degraded" ("another client is polling this bot
token") rather than a false "connected".
openDisconnect imported dialog-connect-remote just to reach DialogDisconnectRemote, dragging the whole pairing state machine (token input, capture poll, spinner, toast) into the disconnect path's chunk. Move the disconnect confirm into dialog-disconnect-remote.tsx so it lazy-loads only the small confirm dialog; the connect flow is unchanged.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/app/src/components/dialog-connect-remote.tsx (1)
97-104:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winMask the bot token input instead of rendering it in plain text.
The pairing token is a secret, but the field is currently
type="text". This exposes it on screen and during entry.Suggested patch
- <TextField + <TextField autofocus - type="text" + type="password" label={language.t("settings.remote.connect.token.label")} placeholder={language.t("settings.remote.connect.token.placeholder")} name="token"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app/src/components/dialog-connect-remote.tsx` around lines 97 - 104, The token input field in the remote connection dialog is exposing the secret pairing token as plain text on screen, creating a security vulnerability. Change the `type` attribute of the TextField component that contains `name="token"` from "text" to "password" to mask the token input during entry, preventing it from being visible on screen or captured in screenshots.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@packages/app/src/components/dialog-connect-remote.tsx`:
- Around line 97-104: The token input field in the remote connection dialog is
exposing the secret pairing token as plain text on screen, creating a security
vulnerability. Change the `type` attribute of the TextField component that
contains `name="token"` from "text" to "password" to mask the token input during
entry, preventing it from being visible on screen or captured in screenshots.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: c36c9290-7ce4-4900-ad6b-dc16ee4d30bb
📒 Files selected for processing (17)
packages/app/src/components/dialog-connect-remote.tsxpackages/app/src/components/dialog-disconnect-remote.tsxpackages/app/src/pages/settings/remote.tsxpackages/desktop-electron/src/main/index.tspackages/desktop-electron/src/main/remote-bridge.test.tspackages/desktop-electron/src/main/remote-bridge.tspackages/desktop-electron/src/main/remote-credentials.test.tspackages/desktop-electron/src/main/remote-credentials.tspackages/remote-bridge/src/engine.test.tspackages/remote-bridge/src/engine.tspackages/remote-bridge/src/gateway.test.tspackages/remote-bridge/src/gateway.tspackages/remote-bridge/src/i18n.test.tspackages/remote-bridge/src/i18n.tspackages/remote-bridge/src/platforms/telegram.test.tspackages/remote-bridge/src/platforms/telegram.tspackages/remote-bridge/src/types.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- packages/desktop-electron/src/main/remote-credentials.test.ts
- packages/desktop-electron/src/main/index.ts
- packages/desktop-electron/src/main/remote-credentials.ts
- packages/remote-bridge/src/platforms/telegram.test.ts
- packages/app/src/pages/settings/remote.tsx
The connect dialog showed a "Telegram connected — you can now message your agent" success toast the moment confirmPairing() returned, but the runtime only reaches "connected" later, on the first live poll (onReady). With the 409 bounding, an Allow can resolve and then end up "degraded", so the toast claimed a working bridge that silently received nothing. Allow now just signals the page (onApproved); the page fires the success toast only when status actually transitions to "connected", and never when it ends in degraded/disconnected (the red status row carries the cause). The decision is a pure connectToastAction(), unit-tested for the 409 and auto-reconnect cases.
Summary
Adds the mobile companion foundation plus its first platform (Telegram, manual token). From a chat app on your phone you can drive a PawWork desktop session: send prompts, read replies, and answer permission / question prompts. The desktop connects out to the Telegram Bot API and long-polls — nothing needs to be reachable from the internet.
remote-bridgeTelegram adapter (packages/remote-bridge/src/platforms/telegram.ts) — a thin wrapper over the raw Bot API (getUpdateslong-poll,sendMessage) implementing the existingPlatformseam. No bot-framework dependency; the package stays zero-adapter-dep. Handles message chunking (4096-code-unit cap),429 retry_after, and fatal vs. transient errors.captureFirstSender) — closed by default. On connect, the bot waits for your first private message and only that sender is allowed to drive the agent. No wildcard / open audience.remote-bridge.ts) — serialized start/stop (no double poller), optimisticconnecting → connected,degradedon run failure. Credentials stored main-process only via ElectronsafeStorage(encrypted at rest,0o600); the renderer only ever sees masked status.Why
Issue #1188 (maintainer P0): let people monitor and interact with running agents from their phone. The earlier Go bridge (#1275) was merged as a TS port (#1336) that left only the
Platformseam, with no adapter. This PR fills that seam with a real platform, keeping to the desktop ethos: download-and-use, no public IP, no port forwarding, no ops.Scope is deliberately small and built to extend: one platform, manual token. WeChat / Feishu / Slack / Discord / QR onboarding and the per-platform registry are explicitly deferred to follow-up PRs — the
Platforminterface is the extension point.Related Issue
Part of #1188. Supersedes #1334 (same goal, rebuilt on the in-process bridge; #1334 is closed).
Human Review Status
PendingReview Focus
telegram.tscaptureFirstSender+remote-bridge.ts): the capture poll and the real bridge must nevergetUpdateson the same token at once (Telegram returns 409). Offset is handed off viamaxUpdateId + 1and the backlog is drained first so a stale message can't pair the wrong account.remote-credentials.ts): token lives only in the main process viasafeStorage; it never crosses the renderer-facingstore-get/store-setIPC. Renderer gets masked status only.remote-bridge.ts): single-entry serialized connect/disconnect,App.run()failure →degraded(never an unhandled rejection), idempotent stop with timeout, started afterhealth.waitand torn down beforekillSidecar.Risk Notes
userData/remote-bridge-credentials.json(+remote-bridge-state.json), mode0o600. Disconnect deletes both.safeStorageis required — if encryption is unavailable the store throws rather than writing plaintext.safeStoragebacks onto Keychain (macOS) / DPAPI (Windows);userDatapaths and the main-process lifecycle were considered for both. No packaging/updater/signing surface changed.@opencode-ai/remote-bridgeandallowImportingTsExtensionsin the desktop tsconfig (consume the package's.tssource; valid undernoEmit).bun.lockupdated accordingly.How To Verify
New Telegram tests cover: message chunking (incl. surrogate pairs), allowlist silent-drop,
getUpdatesoffset handoff, bad-token rejection → degraded, capture drain/ignore-group/abort. Runtime tests cover: single live bridge under concurrent connect, fatal run → degraded (not unhandled), full pairing lifecycle (start → confirm saves → disconnect wipes).Screenshots or Recordings
Live in-app screenshots pending a packaged build (see Risk Notes). The implemented UI follows the design mockup reviewed with the maintainer: Settings → Remote access with a Telegram row (status icon + Connect/Disconnect), a 3-step connect dialog (paste token → "message your bot" wait → "allow this account" approve), and a danger-confirm disconnect dialog.
Checklist
bug,enhancement,task,documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.app,ui,platform,harness,ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.P0,P1,P2,P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.Pending,Approved by @<reviewer>, orNot required: <reason>(default isPending; "not required" is restricted to bot-authored low-risk PRs).dev, and my PR title and commit messages use Conventional Commits in English.Summary by CodeRabbit
Release Notes