Skip to content

apps/cli: Telegram remote-session bridge for studio code (PoC) - #3196

Merged
gcsecsey merged 14 commits into
trunkfrom
stu-1612-complete-poc-for-telegram-to-studio-code-e2e-communication
Apr 28, 2026
Merged

apps/cli: Telegram remote-session bridge for studio code (PoC)#3196
gcsecsey merged 14 commits into
trunkfrom
stu-1612-complete-poc-for-telegram-to-studio-code-e2e-communication

Conversation

@epeicher

@epeicher epeicher commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Related issues

How AI was used in this PR

This is a Proof of Concept built end-to-end by Claude Code from a written spec at specs/studio-code-remote-session-spec.md. The spec itself was iterated against the actual studio code --json event stream and the real /local-agent-poll response shape; the design then pivoted twice based on review feedback (token fallback to the WP.com OAuth login, then chat_id/bot derived per-message instead of pre-bound). All 4 commits show that progression.

Reviewers should focus on:

  • Whether the security model — "the server's bearer is the only auth boundary; any polled message is authorized for this local agent" — is acceptable, or if the optional chat_id pin should be required.
  • The subprocess strategy in apps/cli/remote-session/turn-runner.ts (re-execing process.execPath + process.argv[1]) and the stale---resume-session heuristic.
  • Architecture trade-offs called out in the spec, especially the v1 decision to block the REPL during /remote-session attach.

Proposed Changes

A new apps/cli/remote-session/ module that lets studio code be driven from a Telegram chat by polling the existing wpcom/v2/telegram-bot/local-agent-poll endpoint. Per-message turn = spawn studio code --json [--resume-session <id>] -- <text>, parse NDJSON, post the result (or flattened question.asked) back via /local-agent-respond.

Entry point: studio code --remote-session [--remote-chat-id <id>] [--remote-bot <name>] — headless mode, intended for launchd or a dedicated terminal tab.

Important

The entry point is gated behind STUDIO_ENABLE_REMOTE_SESSION=true (default OFF). With the flag unset, the experimental options don't appear in studio code --help.

Zero-config for logged-in users: token falls back to the WP.com OAuth accessToken in ~/.studio/shared.json. bot and chat_id are optional pins (unset = derive both from each polled message; set = filter/override).

New module:

  • config.ts — zod-validated config + WP.com OAuth fallback.
  • state.ts — per-chat session-id store, lockfile-guarded, mode 0600.
  • telegram-client.tspollMessages / respondMessage with explicit error taxonomy (TelegramAuthError, TelegramTransientError, TelegramBadRequestError).
  • turn-runner.ts — subprocess + NDJSON parse + SIGTERM/SIGKILL on timeout.
  • reply-formatter.tsextractReply() (success / paused / null) + chunker that preserves fenced code blocks.
  • poll-loop.ts — controller: drain batch → /new ack → handle turn → post chunks → stale-session retry → backoff/auth/fatal handling.
  • index.ts — entry that wires signals (SIGINT/SIGTERM → graceful detach) and the best-effort exit POST.
  • 8 test files + a scripted Node fixture for the subprocess harness — 56 unit tests.

Touchpoints in existing code:

  • apps/cli/commands/ai/index.ts — adds --remote-session, --remote-chat-id, --remote-bot; extends the slash-command dispatcher to split on whitespace before lookup (was exact-match only) so subcommand arguments work.
  • apps/cli/ai/slash-commands.ts — registers /remote-session with handler.
  • tools/common/constants.ts + tools/common/lib/well-known-paths.ts — adds path helpers + lockfile name.
  • specs/studio-code-remote-session-spec.md — full spec the implementation tracks.

Testing Instructions

Build and run:

npm run cli:build
STUDIO_ENABLE_REMOTE_SESSION=true node apps/cli/dist/cli/main.mjs code --remote-session

Expected with WP.com login already present in ~/.studio/shared.json: the process attaches and starts polling silently (no Telegram attach POST when chat is not pinned).

Then send a message from Telegram to the bot. The reply should appear in the same chat within a few seconds. Subsequent messages share the session — verify by referring back to earlier turns. Send /new from Telegram to reset the session.

Optional kiosk-style pinning:

STUDIO_ENABLE_REMOTE_SESSION=true STUDIO_REMOTE_CHAT_ID=<your_chat_id> STUDIO_REMOTE_BOT=<bot_name> \
  node apps/cli/dist/cli/main.mjs code --remote-session

With pinning, the controller posts 🟢 Local agent attached. on attach and drops messages from other chats.

To verify the feature is hidden by default:

node apps/cli/dist/cli/main.mjs code --remote-session # Expected: starts a normal interactive code session
node apps/cli/dist/cli/main.mjs code --help # Confirm the remote-session flag is hidden

To exercise the test suite:

npm test -- --project cli apps/cli/remote-session

(56 tests, all passing locally; full CLI suite at 482/482.)

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors? (typecheck clean; lint clean)
  • PoC scope decision: confirm whether the "no chat pin" default is acceptable for v1 or whether chat_id should be required.
  • Phase-0 probes still open in the spec: server Markdown handling, exact stale---resume-session signal, normal-stderr behavior.
  • Decide if /remote-session attach blocking the REPL is acceptable for v1 (vs. a parallel mode).
Lets the user drive studio code from Telegram. Each polled message resumes
the bound chat's session via studio code --json --resume-session, and the
final result text (or flattened question.asked) is posted back. Reachable
via a top-level --remote-session flag for launchd-style use, or via
/remote-session attach inside the interactive REPL (blocks the REPL until
detach).

Includes the spec the implementation was built from.
The server returns { messages: [ { message, chat_id, bot, user_id, timestamp } ] },
not the flat { chat_id, text, bot } the spec drafted. The user's text is in
`message`, not `text`, and a single poll can carry several pending messages.

pollMessage → pollMessages (returns []), and the poll loop now drains the batch
sequentially — one studio code --json turn per entry — before polling again.
Spec and tests updated to match.
- Architecture diagram and poll-loop pseudocode now drain a batch instead
  of handling one message at a time, matching the real /local-agent-poll
  shape.
- Added a "Why chat_id and bot are pre-configured" section so future
  readers don't repeat the question of why those values aren't taken
  straight from the polled message (scoping/security fence and reply
  identity pinning).
The server's bearer token is the auth boundary; any message returned by
/local-agent-poll is already authorized for this local agent. Drop the
requirement to pre-configure chat_id and bot:

- token is the only required config field; still falls back to the WP.com
  OAuth access token from ~/.studio/shared.json when unset
- chat_id and bot become optional pins. When unset, the poll loop processes
  any polled message and echoes the reply target (chat_id + bot) from the
  inbound payload. When set, they keep their prior role: chat_id filters
  inbound messages, bot overrides outgoing identity
- attach/detach/process-exit POSTs only fire when chat_id is pinned (there
  is no chat to post to otherwise)
- state is now keyed by each polled chat_id, not by config

With this change a logged-in user can run
`node apps/cli/dist/cli/main.mjs code --remote-session` with no config at
all and it Just Works.

Spec and tests updated.

@gcsecsey gcsecsey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the whole flow with the companion PR 212845-ghe-Automattic/wpcom, and it works great for a POC. 👍

initial prompt result preview site
Image Image Image

I'll add more detailed testing steps to the companion PR, because the test bot needs some setup steps. I'll also look into the code in more detail tomorrow.

…ills in-flight child

Without a signal, Ctrl-C / detach only aborted the poll fetch; an active turn ran until turn_timeout_seconds (default 15 min). handleTurn now takes the controller's signal, forwards it to both runTurn calls, skips the stale-session retry once aborted, and returns silently — announceDetach posts the detach notice instead of a misleading "Turn took too long" reply.
Comment thread apps/cli/remote-session/turn-runner.ts
…SION env flag (STU-1654)

Default OFF so the experimental Telegram bridge stays out of the stable CLI experience. With the flag unset, the --remote-session/--remote-chat-id/--remote-bot/--message-from-stdin options are not registered on `studio code`, the /remote-session slash command is hidden from autocomplete and the dispatcher won't match it, and the corresponding handler branches are no-ops even if the flags somehow reach argv. Internal users opt in by setting STUDIO_ENABLE_REMOTE_SESSION=true with no rebuild needed.
…c-for-telegram-to-studio-code-e2e-communication

# Conflicts:
#	apps/cli/commands/ai/index.ts
…ile to fix Windows race

`fs.createWriteStream` opens lazily and `.end()` queues the close — neither was awaited, so when a safe entry was the last operation in `extractFiles` (e.g. one blocked + one safe entry), the caller's synchronous `existsSync` could race the NTFS open on Windows. Linux/macOS's libuv worker happened to flush fast enough to mask it. Awaiting the stream's `close` event before returning closes the race cross-platform.
@gcsecsey
gcsecsey requested a review from youknowriad April 28, 2026 10:36
@gcsecsey
gcsecsey marked this pull request as ready for review April 28, 2026 10:36
@wpmobilebot

wpmobilebot commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing 34484f6 vs trunk

app-size

Metric trunk 34484f6 Diff Change
App Size (Mac) 1453.98 MB 1454.02 MB +0.04 MB ⚪ 0.0%

site-editor

Metric trunk 34484f6 Diff Change
load 1786 ms 1504 ms 282 ms 🟢 -15.8%

site-startup

Metric trunk 34484f6 Diff Change
siteCreation 8088 ms 8080 ms 8 ms ⚪ 0.0%
siteStartup 4938 ms 4956 ms +18 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

Per review feedback: with the poll loop blocking the REPL, attaching from inside `studio code` doesn't add much over the `--remote-session` flag, so defer the slash command (and the dispatcher's whitespace tokenization that existed only to support its subcommands) until a non-blocking REPL mode lands. The `enabled?: () => boolean` gate on SlashCommandDef and the getActiveSlashCommands helper go with it — `--remote-session` itself is still gated by STUDIO_ENABLE_REMOTE_SESSION via the existing feature-flags helper. Spec gets a status note marking the slash sections as deferred so the design isn't lost.
@gcsecsey

gcsecsey commented Apr 28, 2026

Copy link
Copy Markdown
Member

As discussed on p1777374476404299/1777372569.384689-slack-C0AULRXLJQL, I removed the slash-commands from this initial version, and also removed these from the PR description.

I'll merge this once CI passes.

…mplete-poc-for-telegram-to-studio-code-e2e-communication
@gcsecsey
gcsecsey enabled auto-merge (squash) April 28, 2026 12:40
@gcsecsey
gcsecsey merged commit 484da49 into trunk Apr 28, 2026
10 checks passed
@gcsecsey
gcsecsey deleted the stu-1612-complete-poc-for-telegram-to-studio-code-e2e-communication branch April 28, 2026 12:45
gcsecsey added a commit that referenced this pull request May 1, 2026
…3272)

## Related issues

- Related to STU-1652
- Builds on the Telegram remote-session bridge from #3196
- Companion PR: 213611-ghe-Automattic/wpcom

## How AI was used in this PR

Claude wrote the bulk of the implementation and the tests. I reviewed
and tested the changes end-to-end in my sandbox using the companion PR.

## Proposed Changes

The Telegram remote-session bridge is currently text-only. When the
agent finishes a visible task, the user gets a prose summary but no
image. This PR lets the local agent deliver screenshots inline:

- New `share_screenshot` tool. Captures a 16:9 above-the-fold view of a
URL by default and emits a `media.share` JSON event. `fullPage: true` is
opt-in for the rare case where the user wants the whole scroll length.
`take_screenshot` stays unchanged as the model-internal reasoning tool.
- Remote-session controller (`turn-runner` + `poll-loop`) collects
`media.share` events from the spawned `studio code --json` child and
posts each photo before the text reply.
- `respondMessage` now picks transport based on payload. Photo present
means `multipart/form-data` with a `photo` file part (matches the wpcom
contract); text-only stays on the existing JSON path.
- Spawned child gets `STUDIO_REMOTE_SESSION=1` so the system prompt
knows to keep replies short, deliver visible work via
`share_screenshot`, follow up with a "Want me to publish this as a
preview site?" line, and stop fabricating "gist stored / preview link
saved" epilogues that aren't backed by any actual storage.

## Testing Instructions

#### Prerequisites

- Be an Automattician (backend gates on `is_automattician()`)
- Be logged in via `studio auth login` so the bearer falls through from
`~/.studio/shared.json`
- Follow the testing steps on 213611-ghe-Automattic/wpcom to apply the
backend changes on your sandbox

#### Testing

- Build the CLI: `STUDIO_ENABLE_REMOTE_SESSION=true npm run cli:build`
- Start the bridge: `STUDIO_ENABLE_REMOTE_SESSION=true node
apps/cli/dist/cli/main.mjs code --remote-session`
- In a second terminal, tail the log: `tail -F
~/.studio/remote-session.log`
- From Telegram, send: "send to my local agent: take a screenshot of my
site and show me"
- Check in Telegram:
  - A 1280x720 above-the-fold screenshot arrives inline
  - A follow-up text message asks about publishing a preview site
- Test for the text-only regressions by sending a non-visual request
like "list my local sites"
- Ask explicitly for a full page screenshot and confirm
`share_screenshot` is called with `fullPage: true` and the long capture
is delivered

## Pre-merge Checklist

- [ ] Have you checked for TypeScript, React or other console errors?

---------

Co-authored-by: Roberto Aranda <roberto.aranda@automattic.com>
gcsecsey added a commit that referenced this pull request May 5, 2026
)

## Related issues

- Fixes STU-1649

## How AI was used in this PR

Implementation drafted with Claude Code, I reviewed the diff, ran the
test suite, and smoke-tested the new CLI surface locally.

## Proposed Changes

The experimental Telegram remote-session bridge added in #3196
(STU-1612) blocks the terminal it was launched from. If the user closes
that terminal, the bridge dies and Studio loses connectivity to the
remote agent. This PR makes the bridge daemonizable so users can close
the terminal without dropping the session.

New subcommand tree under `studio code`, gated behind
`STUDIO_ENABLE_REMOTE_SESSION=true`:

- `studio code remote-session start [--detach] [--remote-chat-id N]
[--remote-bot foo]`. Without `--detach` it behaves like `studio code
--remote-session`. With `--detach` it forks a detached child via
`spawn(..., { detached: true, stdio: 'ignore', windowsHide: true })`,
sets `STUDIO_REMOTE_SESSION_DAEMON_CHILD=1` on the child env, and waits
up to 5s for the child to write `~/.studio/remote-session.pid` (mode
0600) before returning. Config (token) is validated in the parent, so a
missing token fails fast in the foreground.
- `studio code remote-session stop`. Reads the PID file, sends SIGTERM,
polls for exit up to 5s, escalates to SIGKILL if needed, and always
removes the PID file. The daemon's existing SIGTERM handler keeps the
graceful detach path, so chats still receive `🔴 Local agent detached.`
- `studio code remote-session status`. Probes liveness via
`process.kill(pid, 0)`, removes stale PID files automatically, and
prints a human-readable status.

The pre-existing `studio code --remote-session` flag continues to work
unchanged.

## Testing Instructions

- `npm run cli:build`
- `export STUDIO_ENABLE_REMOTE_SESSION=true` and make sure you have a
remote-session token (logging in with `/login` inside `studio code` is
enough)
- `node apps/cli/dist/cli/main.mjs code remote-session status` should
print `not running`
- `node apps/cli/dist/cli/main.mjs code remote-session start --detach`
should print `Started (PID …, log: …)` and return
- Re-run `status` from a fresh terminal: should still report running
- Send a Telegram message to your test bot: the local agent should
respond
- `node apps/cli/dist/cli/main.mjs code remote-session stop` should
print stopped, and Telegram should receive the detach message.

Negative path
- In a clean DEV_CONFIG_DIR with no token, `node
apps/cli/dist/cli/main.mjs code remote-session start --detach` should
print the missing-token error in the foreground and exit non-zero with
no daemon spawned and no PID file written
- Confirm `studio code --help` does NOT show `remote-session` when the
feature flag is off

| Scenario | Command | Output |
| --- | --- | --- |
| No daemon yet | `studio code remote-session status` | <img
width="1000" height="148" alt="CleanShot 2026-05-01 at 10 48 35@2x"
src="https://github.com/user-attachments/assets/e8d48b10-0d92-46a2-ba1e-c8bfc16d9c46"
/> |
| Start as daemon | `studio code remote-session start --detach` | <img
width="1510" height="146" alt="CleanShot 2026-05-01 at 10 48 56@2x"
src="https://github.com/user-attachments/assets/cb9104b0-92c1-4aa0-a7d9-f5c398789bb8"
/> |
| Confirm from a fresh terminal | `studio code remote-session status` |
<img width="1506" height="208" alt="CleanShot 2026-05-01 at 10 49 30@2x"
src="https://github.com/user-attachments/assets/a3e9fc7d-ca6a-4474-8ec3-27b8d772f2b8"
/> |
| Bridge end-to-end | Telegram chat | <img width="1736" height="1538"
alt="CleanShot 2026-05-01 at 10 53 52@2x"
src="https://github.com/user-attachments/assets/7f4ac4e5-b4fd-45fa-a924-642fe90ff586"
/> |
| Graceful stop | `studio code remote-session stop` | <img width="1506"
height="144" alt="CleanShot 2026-05-01 at 10 54 09@2x"
src="https://github.com/user-attachments/assets/f70e1885-88d1-4ffb-acb3-96350fdffb18"
/> |
| Back to clean state | `studio code remote-session status` | <img
width="1506" height="144" alt="CleanShot 2026-05-01 at 10 54 20@2x"
src="https://github.com/user-attachments/assets/1a2f5f42-a0d2-4fd5-9ad6-228877918ee5"
/> |
| Already running | `studio code remote-session start --detach` (second
time) | <img width="1618" height="144" alt="CleanShot 2026-05-01 at 10
54 39@2x"
src="https://github.com/user-attachments/assets/17ee49cf-b2f5-4e22-b8cc-67907ce6ffc4"
/> |
| No token configured | `studio code remote-session start --detach`
(clean DEV_CONFIG_DIR) | <img width="1618" height="212" alt="CleanShot
2026-05-01 at 10 55 38@2x"
src="https://github.com/user-attachments/assets/b549acc2-1e85-4d8f-80ad-1e8fd1c888b6"
/> |
| Stop when not running | `studio code remote-session stop` | <img
width="1486" height="124" alt="CleanShot 2026-05-01 at 10 56 02@2x"
src="https://github.com/user-attachments/assets/1cacc9fd-090f-4f9b-915e-33c6b39b36fa"
/> |
| Stale PID file | `studio code remote-session status` (after editing
PID file to a dead PID) | <img width="1486" height="126" alt="CleanShot
2026-05-01 at 10 57 52@2x"
src="https://github.com/user-attachments/assets/c438569a-aa90-4575-9adf-4d2f198d5a7c"
/> |
| Feature flag off | `studio code --help` | <img width="1486"
height="674" alt="CleanShot 2026-05-01 at 10 59 31@2x"
src="https://github.com/user-attachments/assets/80490b88-042c-4e14-99aa-23fcef87ce60"
/> |
| Feature flag on | `studio code --help` | <img width="1486"
height="874" alt="CleanShot 2026-05-01 at 10 58 09@2x"
src="https://github.com/user-attachments/assets/c6483ee1-e204-4eb3-8c94-ed5e6fe6d1a8"
/> |

## Pre-merge Checklist

- [x] Have you checked for TypeScript, React or other console errors?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

3 participants