Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Studio Code: detach remote-session by default and add attach subcom…
…mand

`studio code remote-session start` now daemonizes by default — pass
`--no-detach` to keep running attached in the foreground. The new
`attach` subcommand connects to a running daemon and streams its log
file to stdout in the same human-readable format used by foreground
mode; Ctrl-C detaches the terminal without stopping the daemon.
  • Loading branch information
epeicher committed May 5, 2026
commit 80de1e16bb4d0d1973dc17c920ffaa7ef436fe50
49 changes: 45 additions & 4 deletions apps/cli/commands/ai/remote-session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getRemoteSessionLogPath } from '@studio/common/lib/well-known-paths';
import { __, sprintf } from '@wordpress/i18n';
import { DaemonNotRunningError, runAttach } from 'cli/remote-session/attach';
import { loadRemoteSessionConfig } from 'cli/remote-session/config';
import {
DaemonAlreadyRunningError,
Expand Down Expand Up @@ -51,7 +52,9 @@ async function runStart( argv: StartArgs ): Promise< void > {
process.stdout.write(
sprintf(
/* translators: 1: PID, 2: log file path */
__( 'Remote-session daemon started (PID %1$d). Logs: %2$s\n' ),
__(
'Remote-session daemon started (PID %1$d). Logs: %2$s\nRun `studio code remote-session attach` to follow them.\n'
),
result.pid,
getRemoteSessionLogPath()
)
Expand Down Expand Up @@ -156,20 +159,51 @@ async function runStop(): Promise< void > {
);
}

async function runAttachCommand(): Promise< void > {
const controller = new AbortController();
const onSignal = () => controller.abort();
process.on( 'SIGINT', onSignal );
process.on( 'SIGTERM', onSignal );
try {
await runAttach( { signal: controller.signal } );
if ( controller.signal.aborted ) {
process.stdout.write( __( 'Detached. Daemon is still running.\n' ) );
}
} catch ( error ) {
if ( error instanceof DaemonNotRunningError ) {
process.stderr.write(
__(
'Remote-session daemon is not running. Start it with `studio code remote-session start`.\n'
)
);
process.exitCode = 1;
return;
}
throw error;
} finally {
process.off( 'SIGINT', onSignal );
process.off( 'SIGTERM', onSignal );
}
}

export const registerRemoteSessionCommand = ( yargs: StudioArgv ) => {
return yargs.command(
'remote-session',
__( 'Manage the Telegram remote-session daemon' ),
( remoteYargs ) => {
remoteYargs.command( {
command: 'start',
describe: __( 'Start the remote-session bridge (foreground or detached)' ),
describe: __(
'Start the remote-session bridge as a background daemon (use --no-detach to run in the foreground)'
),
builder: ( startYargs ) =>
startYargs
.option( 'detach', {
type: 'boolean',
default: false,
description: __( 'Run as a background daemon and return immediately' ),
default: true,
description: __(
'Run as a background daemon and return immediately (default). Use --no-detach to run attached in the foreground.'
),
} )
.option( 'remote-chat-id', {
type: 'number',
Expand All @@ -193,6 +227,13 @@ export const registerRemoteSessionCommand = ( yargs: StudioArgv ) => {
describe: __( 'Show the remote-session daemon status' ),
handler: runStatus,
} );
remoteYargs.command( {
command: 'attach',
describe: __(
'Attach to a running remote-session daemon and stream its logs (Ctrl-C detaches)'
),
handler: runAttachCommand,
} );
remoteYargs
.version( false )
.demandCommand( 1, __( 'You must provide a valid remote-session command' ) );
Expand Down
180 changes: 180 additions & 0 deletions apps/cli/remote-session/attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import fs from 'fs';
import {
getRemoteSessionLogPath,
getRemoteSessionPidPath,
} from '@studio/common/lib/well-known-paths';
import { getDaemonStatus } from 'cli/remote-session/daemon';
import { formatLogLineForStdout } from 'cli/remote-session/logger';

const POLL_INTERVAL_MS = 250;
const DAEMON_LIVENESS_INTERVAL_MS = 1000;
/** Bytes of log history to replay on attach (tail-style). */
const INITIAL_TAIL_BYTES = 16 * 1024;

export class DaemonNotRunningError extends Error {
constructor() {
super( 'Remote-session daemon is not running.' );
this.name = 'DaemonNotRunningError';
}
}

export interface AttachOptions {
pidFile?: string;
logFile?: string;
out?: NodeJS.WritableStream;
signal?: AbortSignal;
pollIntervalMs?: number;
livenessIntervalMs?: number;
initialTailBytes?: number;
}

interface ReaderState {
offset: number;
carry: string;
}

function readNew( logFile: string, state: ReaderState ): string[] {
let stat: fs.Stats;
try {
stat = fs.statSync( logFile );
} catch {
return [];
}
const size = stat.size;
if ( size === state.offset ) {
return [];
}
if ( size < state.offset ) {
// File rotated or truncated.
state.offset = 0;
state.carry = '';
}
const fd = fs.openSync( logFile, 'r' );
try {
const length = size - state.offset;
const buf = Buffer.alloc( length );
fs.readSync( fd, buf, 0, length, state.offset );
state.offset = size;
const text = state.carry + buf.toString( 'utf8' );
const lastNl = text.lastIndexOf( '\n' );
if ( lastNl === -1 ) {
state.carry = text;
return [];
}
state.carry = text.slice( lastNl + 1 );
return text
.slice( 0, lastNl )
.split( '\n' )
.filter( ( line ) => line.length > 0 );
} finally {
fs.closeSync( fd );
}
}

function seekTail( logFile: string, tailBytes: number ): ReaderState {
let stat: fs.Stats;
try {
stat = fs.statSync( logFile );
} catch {
return { offset: 0, carry: '' };
}
if ( stat.size <= tailBytes ) {
return { offset: 0, carry: '' };
}
// Skip past the first partial line so we don't print a truncated entry.
const fd = fs.openSync( logFile, 'r' );
try {
const start = stat.size - tailBytes;
const buf = Buffer.alloc( tailBytes );
fs.readSync( fd, buf, 0, tailBytes, start );
const firstNl = buf.indexOf( 0x0a );
const safeStart = firstNl === -1 ? stat.size : start + firstNl + 1;
return { offset: safeStart, carry: '' };
} finally {
fs.closeSync( fd );
}
}

function isProcessAlive( pid: number ): boolean {
try {
process.kill( pid, 0 );
return true;
} catch ( error ) {
const code = ( error as NodeJS.ErrnoException ).code;
return code === 'EPERM';
}
}

/**
* Attach to a running remote-session daemon by streaming its log file to stdout.
* Returns when the abort signal fires (Ctrl-C) or when the daemon process exits.
* Does not stop the daemon — detaching the terminal is the only side effect.
*/
export async function runAttach( options: AttachOptions = {} ): Promise< void > {
const pidFile = options.pidFile ?? getRemoteSessionPidPath();
const logFile = options.logFile ?? getRemoteSessionLogPath();
const out = options.out ?? process.stdout;
const pollMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
const livenessMs = options.livenessIntervalMs ?? DAEMON_LIVENESS_INTERVAL_MS;
const tailBytes = options.initialTailBytes ?? INITIAL_TAIL_BYTES;

const status = getDaemonStatus( pidFile );
if ( ! status.running || status.pid === undefined ) {
throw new DaemonNotRunningError();
}
const daemonPid = status.pid;

out.write( `Attached to remote-session daemon (PID ${ daemonPid }).\n` );
out.write( `Log: ${ logFile }\n` );
out.write( 'Press Ctrl-C to detach (daemon keeps running).\n' );

const state = seekTail( logFile, tailBytes );
// Drain whatever is already past the tail anchor before entering the loop.
for ( const line of readNew( logFile, state ) ) {
out.write( formatLogLineForStdout( line ) );
}

let lastLiveness = Date.now();
while ( true ) {
if ( options.signal?.aborted ) {
return;
}
await sleepAbortable( pollMs, options.signal );
if ( options.signal?.aborted ) {
return;
}
for ( const line of readNew( logFile, state ) ) {
out.write( formatLogLineForStdout( line ) );
}
const now = Date.now();
if ( now - lastLiveness >= livenessMs ) {
lastLiveness = now;
if ( ! isProcessAlive( daemonPid ) ) {
// Final drain before reporting exit.
for ( const line of readNew( logFile, state ) ) {
out.write( formatLogLineForStdout( line ) );
}
out.write( `Remote-session daemon (PID ${ daemonPid }) exited.\n` );
return;
}
}
}
}

function sleepAbortable( ms: number, signal?: AbortSignal ): Promise< void > {
return new Promise( ( resolve ) => {
if ( signal?.aborted ) {
resolve();
return;
}
const timer = setTimeout( () => {
signal?.removeEventListener( 'abort', onAbort );
resolve();
}, ms );
const onAbort = () => {
clearTimeout( timer );
resolve();
};
signal?.addEventListener( 'abort', onAbort, { once: true } );
} );
}
41 changes: 41 additions & 0 deletions apps/cli/remote-session/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,47 @@ function formatStdoutLine(
return `${ head } ${ safeMeta }\n`;
}

interface PersistedLogLine {
t?: unknown;
level?: unknown;
msg?: unknown;
meta?: unknown;
}

/**
* Reformat one JSON log line (as written by RemoteSessionLogger) into the same
* human-readable shape used when mirroring to stdout. Falls back to the raw
* line (sanitized + newline-terminated) when parsing fails.
*/
export function formatLogLineForStdout( rawLine: string ): string {
const trimmed = rawLine.replace( /\r?\n$/, '' );
if ( trimmed.length === 0 ) {
return '\n';
}
let parsed: PersistedLogLine;
try {
parsed = JSON.parse( trimmed ) as PersistedLogLine;
} catch {
return `${ stripTerminalControls( trimmed ) }\n`;
}
const level = isLogLevel( parsed.level ) ? parsed.level : 'info';
const message = typeof parsed.msg === 'string' ? parsed.msg : '';
const time =
typeof parsed.t === 'string' && parsed.t.length >= 19
? parsed.t.slice( 11, 19 )
: new Date().toTimeString().slice( 0, 8 );
const head = `[${ time }] ${ level } ${ safeForStdout( message ) }`;
if ( parsed.meta && typeof parsed.meta === 'object' ) {
const safeMeta = safeForStdout( JSON.stringify( parsed.meta ) );
return `${ head } ${ safeMeta }\n`;
}
return `${ head }\n`;
}

function isLogLevel( value: unknown ): value is LogLevel {
return value === 'debug' || value === 'info' || value === 'warn' || value === 'error';
}

export class RemoteSessionLogger {
private logPath: string;
private mirrorToStdout: boolean;
Expand Down
Loading
Loading