Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/cli/commands/ai/remote-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface StartArgs {
detach?: boolean;
remoteChatId?: number;
remoteBot?: string;
avoidTelemetry?: boolean;
}

function buildExtraArgs( argv: StartArgs ): string[] {
Expand All @@ -26,6 +27,11 @@ function buildExtraArgs( argv: StartArgs ): string[] {
if ( typeof argv.remoteBot === 'string' && argv.remoteBot.length > 0 ) {
out.push( '--remote-bot', argv.remoteBot );
}
// The daemon child is a fresh `node studio` invocation, so it does NOT inherit the
// top-level `--avoid-telemetry` flag. Forward it explicitly so the child respects opt-out.
if ( argv.avoidTelemetry ) {
out.push( '--avoid-telemetry' );
}
return out;
}

Expand Down Expand Up @@ -84,10 +90,13 @@ async function runStart( argv: StartArgs ): Promise< void > {
}

try {
await runRemoteSession( {
chat_id: argv.remoteChatId,
bot: argv.remoteBot,
} );
await runRemoteSession(
{
chat_id: argv.remoteChatId,
bot: argv.remoteBot,
},
{ avoidTelemetry: argv.avoidTelemetry }
);
} catch ( error ) {
if ( error instanceof RemoteSessionConfigError ) {
process.stderr.write( `${ error.message }\n` );
Expand Down
20 changes: 20 additions & 0 deletions apps/cli/lib/types/bump-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export enum StatsGroup {
STUDIO_CLI_TOTAL_LAUNCHES_APP = 'studio-cli-lch-tot-app',
STUDIO_CLI_SITE_CREATE_NPM = 'studio-cli-site-crt-npm',
STUDIO_CLI_SITE_CREATE_APP = 'studio-cli-site-crt-app',
// Dolly remote-session (Telegram bot bridge) — see STU-1739.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question about naming, are there any other occurrences of Dolly, if not, should we adopt the wp-agent naming? 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thinking again about this, I think it's fine to consider Dolly as the internal codename

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I was thinking about this too, and was wondering if we could just call this feature "Studio remote sessions" maybe. But overall, I think it's fine to keep the internal naming for now, too.

STUDIO_CLI_DOLLY_START = 'studio-cli-dolly-start',
STUDIO_CLI_DOLLY_ATTACH = 'studio-cli-dolly-attach',
STUDIO_CLI_DOLLY_TURN = 'studio-cli-dolly-turn',
STUDIO_CLI_DOLLY_DETACH = 'studio-cli-dolly-detach',
STUDIO_CLI_DOLLY_WEEKLY_UNIQ = 'studio-cli-dolly-wkly-unq',
STUDIO_CLI_DOLLY_MONTHLY_UNIQ = 'studio-cli-dolly-mon-unq',
}

export enum StatsMetric {
Expand All @@ -20,4 +27,17 @@ export enum StatsMetric {
LINUX = 'linux',
WINDOWS = 'win32',
UNKNOWN_PLATFORM = 'unknown-platform',
// Dolly turn outcomes — mirror `TurnOutcomeStatus` from turn-runner.ts, plus an `aborted`
// bucket for detach-mid-turn (signalled via the abort controller, not the status field).
TURN_ERROR = 'error',
TURN_PAUSED = 'paused',
TURN_MAX_TURNS = 'max-turns',
TURN_TIMEOUT = 'timeout',
TURN_SPAWN_ERROR = 'spawn-error',
TURN_ABORTED = 'aborted',
// Dolly detach reasons — mirror the `reason` arg passed to announceDetach() in poll-loop.ts.
DETACH_REQUESTED = 'requested',
DETACH_LOOP_EXIT = 'loop-exit',
DETACH_AUTH_ERROR = 'auth-error',
DETACH_FATAL_POLL_ERROR = 'fatal-poll-error',
}
34 changes: 33 additions & 1 deletion apps/cli/remote-session/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getRemoteSessionLogPath } from '@studio/common/lib/well-known-paths';
import { bumpAggregatedUniqueStat, bumpStat, getPlatformMetric } from 'cli/lib/bump-stat';
import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats';
import {
RemoteSessionConfigError,
loadRemoteSessionConfig,
Expand All @@ -11,6 +13,11 @@ import { respondMessage } from 'cli/remote-session/telegram-client';

export { RemoteSessionConfigError };

export interface RunRemoteSessionOptions {
/** Honors the top-level `--avoid-telemetry` flag. When true, no bump stats are emitted. */
avoidTelemetry?: boolean;
}

/**
* Entry point for `studio code --remote-session` and `studio code remote-session
* start`. Validates config, enters the poll loop, and returns when the loop
Expand All @@ -19,9 +26,33 @@ export { RemoteSessionConfigError };
* writes a PID file that the `stop` / `status` subcommands and the
* `/remote-session` REPL slash command consult.
*/
export async function runRemoteSession( overrides: RemoteSessionOverrides = {} ): Promise< void > {
export async function runRemoteSession(
overrides: RemoteSessionOverrides = {},
{ avoidTelemetry = false }: RunRemoteSessionOptions = {}
): Promise< void > {
const config = await loadRemoteSessionConfig( overrides );
const runningAsDaemon = isDaemonChild();
const telemetryEnabled = __ENABLE_CLI_TELEMETRY__ && ! avoidTelemetry;

if ( telemetryEnabled ) {
// Total starts per platform (every invocation, daemon child or foreground).
bumpStat( StatsGroup.STUDIO_CLI_DOLLY_START, getPlatformMetric() );
// Weekly/monthly unique = "Dolly active users" headcount, mirroring the launch stats pattern.
bumpAggregatedUniqueStat(
StatsGroup.STUDIO_CLI_DOLLY_WEEKLY_UNIQ,
StatsMetric.SUCCESS,
'weekly'
).catch( () =>
console.error( 'Failed to bump stat:', StatsGroup.STUDIO_CLI_DOLLY_WEEKLY_UNIQ )
);
bumpAggregatedUniqueStat(
StatsGroup.STUDIO_CLI_DOLLY_MONTHLY_UNIQ,
StatsMetric.SUCCESS,
'monthly'
).catch( () =>
console.error( 'Failed to bump stat:', StatsGroup.STUDIO_CLI_DOLLY_MONTHLY_UNIQ )
);
}
if ( runningAsDaemon ) {
// Detached child: claim the PID file. The hook removes it on `exit` and
// (on POSIX) on SIGHUP. SIGINT/SIGTERM go through the existing graceful
Expand Down Expand Up @@ -50,6 +81,7 @@ export async function runRemoteSession( overrides: RemoteSessionOverrides = {} )
const { done, detach } = await runPollLoop( {
config,
deps: { logger },
telemetryEnabled,
onAttached: () => {
if ( runningAsDaemon ) {
return;
Expand Down
70 changes: 65 additions & 5 deletions apps/cli/remote-session/poll-loop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type JsonEvent } from '@studio/common/ai/json-events';
import { bumpStat } from 'cli/lib/bump-stat';
import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats';
import { type RemoteSessionConfig } from 'cli/remote-session/config';
import { RemoteSessionLogger } from 'cli/remote-session/logger';
import { MediaStreamer } from 'cli/remote-session/media-streamer';
Expand Down Expand Up @@ -50,6 +52,29 @@ export interface RunPollLoopOptions {
deps?: Partial< PollLoopDeps >;
/** Called once attach status has been posted, after entering the loop. */
onAttached?: () => void;
/**
* Whether to emit Dolly bump stats (attach/turn/detach). Defaults to false so tests don't
* accidentally bump. The CLI entry point sets this to true when the bundle has telemetry
* compiled in and the user didn't pass `--avoid-telemetry`.
*/
telemetryEnabled?: boolean;
}

function turnStatusMetric( outcomeStatus: TurnOutcome[ 'status' ] ): StatsMetric {
switch ( outcomeStatus ) {
case 'success':
return StatsMetric.SUCCESS;
case 'error':
return StatsMetric.TURN_ERROR;
case 'paused':
return StatsMetric.TURN_PAUSED;
case 'max_turns':
return StatsMetric.TURN_MAX_TURNS;
case 'timeout':
return StatsMetric.TURN_TIMEOUT;
case 'spawn_error':
return StatsMetric.TURN_SPAWN_ERROR;
}
}

function truncate( text: string, max: number ): string {
Expand Down Expand Up @@ -107,7 +132,8 @@ async function handleTurn(
config: RemoteSessionConfig,
target: ReplyTarget,
text: string,
signal: AbortSignal
signal: AbortSignal,
telemetryEnabled: boolean
): Promise< void > {
let sessionId: string | undefined = ( await deps.readState( target.chatId ) )?.session_id;
const started = Date.now();
Expand Down Expand Up @@ -184,6 +210,16 @@ async function handleTurn(
media_failed: mediaSummary.failed,
} );

if ( telemetryEnabled ) {
// `aborted` means the user detached mid-turn — the outcome status will still be 'error' or
// similar, but the meaningful classification is "user-initiated cancel," so bucket it
// separately to avoid skewing the error rate.
bumpStat(
StatsGroup.STUDIO_CLI_DOLLY_TURN,
signal.aborted ? StatsMetric.TURN_ABORTED : turnStatusMetric( outcome.status )
);
}

// Detach was requested mid-turn. Skip posting any reply — the detach flow
// will announce "🔴 Local agent detached." on its own.
if ( signal.aborted ) {
Expand Down Expand Up @@ -248,10 +284,15 @@ async function handleTurn(
export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollLoopHandle > {
const deps: PollLoopDeps = { ...DEFAULT_DEPS, ...options.deps };
const { config } = options;
const telemetryEnabled = options.telemetryEnabled ?? false;

const abortController = new AbortController();
let detachRequested = false;
let detachAnnounced = false;
// When set, takes precedence over the `reason` arg in `announceDetach`. Error paths assign
// here so the detach bump distinguishes auth-error / fatal-poll-error from a clean exit,
// even though they all funnel through the same break-batchLoop → 'loop-exit' code path.
let pendingDetachReason: StatsMetric | undefined;

// Attach status is only posted when the user pinned a `chat_id` in config. Without
// it, we don't know where to post until the first message arrives.
Expand All @@ -275,14 +316,20 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
} else {
deps.logger.info( 'Attached (open to any chat authorized by the bearer)' );
}
if ( telemetryEnabled ) {
bumpStat( StatsGroup.STUDIO_CLI_DOLLY_ATTACH, StatsMetric.SUCCESS );
}
options.onAttached?.();

const announceDetach = async ( reason: string ) => {
const announceDetach = async ( reason: string, detachMetric: StatsMetric ) => {
if ( detachAnnounced ) {
return;
}
detachAnnounced = true;
deps.logger.info( 'Detaching', { reason } );
if ( telemetryEnabled ) {
bumpStat( StatsGroup.STUDIO_CLI_DOLLY_DETACH, pendingDetachReason ?? detachMetric );
}
// Detach status is also only posted when chat_id is pinned.
if ( config.chat_id !== undefined ) {
await postBestEffort(
Expand All @@ -297,7 +344,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
const detach = async () => {
detachRequested = true;
abortController.abort();
await announceDetach( 'requested' );
await announceDetach( 'requested', StatsMetric.DETACH_REQUESTED );
};

const loop = async (): Promise< void > => {
Expand All @@ -318,6 +365,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
'⚠️ Bad token; detaching.'
);
}
pendingDetachReason = StatsMetric.DETACH_AUTH_ERROR;
detachRequested = true;
process.exitCode = 1;
break;
Expand All @@ -338,6 +386,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
deps.logger.error( 'Fatal poll error; detaching', {
error: ( error as Error ).message,
} );
pendingDetachReason = StatsMetric.DETACH_FATAL_POLL_ERROR;
detachRequested = true;
break;
}
Expand Down Expand Up @@ -419,12 +468,20 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
}

try {
await handleTurn( deps, config, target, polled.text, abortController.signal );
await handleTurn(
deps,
config,
target,
polled.text,
abortController.signal,
telemetryEnabled
);
} catch ( error ) {
if ( error instanceof TelegramAuthError ) {
deps.logger.error( 'Auth error during respond; detaching', {
status: error.status,
} );
pendingDetachReason = StatsMetric.DETACH_AUTH_ERROR;
detachRequested = true;
process.exitCode = 1;
break batchLoop;
Expand All @@ -444,7 +501,10 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
}
}

await announceDetach( detachAnnounced ? 'already-announced' : 'loop-exit' );
await announceDetach(
detachAnnounced ? 'already-announced' : 'loop-exit',
StatsMetric.DETACH_LOOP_EXIT
);
};

const done = loop();
Expand Down
Loading