Skip to content

Commit 17b8a5c

Browse files
authored
Studio Code: run the remote-session bridge as a background daemon (#3300)
## 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?
1 parent cc9b462 commit 17b8a5c

7 files changed

Lines changed: 837 additions & 8 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { getRemoteSessionLogPath } from '@studio/common/lib/well-known-paths';
2+
import { __, sprintf } from '@wordpress/i18n';
3+
import { loadRemoteSessionConfig } from 'cli/remote-session/config';
4+
import {
5+
DaemonAlreadyRunningError,
6+
DaemonStartTimeoutError,
7+
getDaemonStatus,
8+
startDaemon,
9+
stopDaemon,
10+
} from 'cli/remote-session/daemon';
11+
import { runRemoteSession, RemoteSessionConfigError } from 'cli/remote-session/index';
12+
import { StudioArgv } from 'cli/types';
13+
14+
interface StartArgs {
15+
detach?: boolean;
16+
remoteChatId?: number;
17+
remoteBot?: string;
18+
}
19+
20+
function buildExtraArgs( argv: StartArgs ): string[] {
21+
const out: string[] = [];
22+
if ( typeof argv.remoteChatId === 'number' ) {
23+
out.push( '--remote-chat-id', String( argv.remoteChatId ) );
24+
}
25+
if ( typeof argv.remoteBot === 'string' && argv.remoteBot.length > 0 ) {
26+
out.push( '--remote-bot', argv.remoteBot );
27+
}
28+
return out;
29+
}
30+
31+
async function runStart( argv: StartArgs ): Promise< void > {
32+
if ( argv.detach ) {
33+
// Validate config in the parent so we fail fast (e.g., missing token)
34+
// instead of silently spawning a child that exits immediately.
35+
try {
36+
await loadRemoteSessionConfig( {
37+
chat_id: argv.remoteChatId,
38+
bot: argv.remoteBot,
39+
} );
40+
} catch ( error ) {
41+
if ( error instanceof RemoteSessionConfigError ) {
42+
process.stderr.write( `${ error.message }\n` );
43+
process.exitCode = 1;
44+
return;
45+
}
46+
throw error;
47+
}
48+
49+
try {
50+
const result = await startDaemon( { extraArgs: buildExtraArgs( argv ) } );
51+
process.stdout.write(
52+
sprintf(
53+
/* translators: 1: PID, 2: log file path */
54+
__( 'Remote-session daemon started (PID %1$d). Logs: %2$s\n' ),
55+
result.pid,
56+
getRemoteSessionLogPath()
57+
)
58+
);
59+
} catch ( error ) {
60+
if ( error instanceof DaemonAlreadyRunningError ) {
61+
process.stderr.write(
62+
sprintf(
63+
/* translators: %d: PID */
64+
__(
65+
'Remote-session daemon is already running (PID %d). Use `studio code remote-session stop` first.\n'
66+
),
67+
error.pid
68+
)
69+
);
70+
process.exitCode = 1;
71+
return;
72+
}
73+
if ( error instanceof DaemonStartTimeoutError ) {
74+
process.stderr.write( `${ error.message }\n` );
75+
process.exitCode = 1;
76+
return;
77+
}
78+
throw error;
79+
}
80+
return;
81+
}
82+
83+
try {
84+
await runRemoteSession( {
85+
chat_id: argv.remoteChatId,
86+
bot: argv.remoteBot,
87+
} );
88+
} catch ( error ) {
89+
if ( error instanceof RemoteSessionConfigError ) {
90+
process.stderr.write( `${ error.message }\n` );
91+
process.exitCode = 1;
92+
return;
93+
}
94+
throw error;
95+
}
96+
}
97+
98+
async function runStatus(): Promise< void > {
99+
const status = getDaemonStatus();
100+
if ( status.running && status.pid !== undefined ) {
101+
process.stdout.write(
102+
sprintf(
103+
/* translators: 1: PID, 2: log path, 3: PID file path */
104+
__( 'Remote-session daemon is running (PID %1$d).\nLogs: %2$s\nPID file: %3$s\n' ),
105+
status.pid,
106+
getRemoteSessionLogPath(),
107+
status.pidFile
108+
)
109+
);
110+
return;
111+
}
112+
if ( status.staleFileRemoved ) {
113+
process.stdout.write(
114+
__( 'Remote-session daemon is not running (cleaned up a stale PID file).\n' )
115+
);
116+
return;
117+
}
118+
process.stdout.write( __( 'Remote-session daemon is not running.\n' ) );
119+
}
120+
121+
async function runStop(): Promise< void > {
122+
const result = await stopDaemon();
123+
if ( result.alreadyStopped ) {
124+
process.stdout.write( __( 'Remote-session daemon is not running.\n' ) );
125+
return;
126+
}
127+
if ( ! result.stopped ) {
128+
process.stderr.write(
129+
sprintf(
130+
/* translators: %d: PID */
131+
__(
132+
'Remote-session daemon (PID %d) did not exit after SIGKILL. PID file left in place.\n'
133+
),
134+
result.pid ?? 0
135+
)
136+
);
137+
process.exitCode = 1;
138+
return;
139+
}
140+
if ( result.usedSigKill ) {
141+
process.stdout.write(
142+
sprintf(
143+
/* translators: %d: PID */
144+
__( 'Remote-session daemon (PID %d) did not exit gracefully; sent SIGKILL.\n' ),
145+
result.pid ?? 0
146+
)
147+
);
148+
return;
149+
}
150+
process.stdout.write(
151+
sprintf(
152+
/* translators: %d: PID */
153+
__( 'Remote-session daemon (PID %d) stopped.\n' ),
154+
result.pid ?? 0
155+
)
156+
);
157+
}
158+
159+
export const registerRemoteSessionCommand = ( yargs: StudioArgv ) => {
160+
return yargs.command(
161+
'remote-session',
162+
__( 'Manage the Telegram remote-session daemon' ),
163+
( remoteYargs ) => {
164+
remoteYargs.command( {
165+
command: 'start',
166+
describe: __( 'Start the remote-session bridge (foreground or detached)' ),
167+
builder: ( startYargs ) =>
168+
startYargs
169+
.option( 'detach', {
170+
type: 'boolean',
171+
default: false,
172+
description: __( 'Run as a background daemon and return immediately' ),
173+
} )
174+
.option( 'remote-chat-id', {
175+
type: 'number',
176+
description: __( 'Override the Telegram chat id to bind to' ),
177+
} )
178+
.option( 'remote-bot', {
179+
type: 'string',
180+
description: __( 'Override the Telegram bot name to use for replies' ),
181+
} ),
182+
handler: async ( argv ) => {
183+
await runStart( argv as StartArgs );
184+
},
185+
} );
186+
remoteYargs.command( {
187+
command: 'stop',
188+
describe: __( 'Stop the running remote-session daemon' ),
189+
handler: runStop,
190+
} );
191+
remoteYargs.command( {
192+
command: 'status',
193+
describe: __( 'Show the remote-session daemon status' ),
194+
handler: runStatus,
195+
} );
196+
remoteYargs
197+
.version( false )
198+
.demandCommand( 1, __( 'You must provide a valid remote-session command' ) );
199+
}
200+
);
201+
};

apps/cli/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ async function main() {
124124
const studioCodeCommandBuilder = async ( aiYargs: StudioArgv ) => {
125125
const { registerCommand: registerAiCommand } = await import( 'cli/commands/ai' );
126126
registerAiCommand( aiYargs );
127+
const { isRemoteSessionEnabled } = await import( 'cli/lib/feature-flags' );
128+
if ( isRemoteSessionEnabled() ) {
129+
const { registerRemoteSessionCommand } = await import( 'cli/commands/ai/remote-session' );
130+
registerRemoteSessionCommand( aiYargs );
131+
}
127132
aiYargs.command( 'sessions', __( 'Manage code sessions' ), async ( sessionsYargs ) => {
128133
const [
129134
{ registerCommand: registerAiSessionsDeleteCommand },

0 commit comments

Comments
 (0)