Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/shaky-flowers-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/agents": patch
---

implement CLI serverInfo protocol
2 changes: 1 addition & 1 deletion agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@livekit/local-inference": "^0.2.6",
"@livekit/mutex": "^1.1.1",
"@livekit/protocol": "^1.48.0",
"@livekit/protocol": "^1.49.0",
"@livekit/throws-transformer": "0.1.8",
"@livekit/typed-emitter": "^3.0.0",
"@opentelemetry/api": "^1.9.0",
Expand Down
23 changes: 22 additions & 1 deletion agents/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-License-Identifier: Apache-2.0
import { Command, Option } from 'commander';
import type { EventEmitter } from 'node:events';
import { CLIClient } from './cli_client.js';
import { runConsole } from './console.js';
import { type PluginDownloadFailure, formatDownloadFailureMessage } from './download.js';
import { initializeLogger, log } from './log.js';
Expand All @@ -19,6 +20,8 @@ type CliArgs = {
event?: EventEmitter;
room?: string;
participantIdentity?: string;
// Address of the driving `lk` CLI's dev channel (set by `lk agent dev`).
cliAddr?: string;
};

const formatErrorMessage = (error: unknown): string =>
Expand All @@ -30,7 +33,15 @@ const runServer = async (args: CliArgs) => {

// though `production` is defined in ServerOptions, it will always be overridden by CLI.
const { production: _, ...opts } = args.opts; // eslint-disable-line @typescript-eslint/no-unused-vars
const server = new AgentServer(new ServerOptions({ ...opts, production: args.production }));
const serverOptions = new ServerOptions({ ...opts, production: args.production });
const server = new AgentServer(serverOptions);

// When launched by `lk agent dev`, report ServerInfo over the CLI's dev channel
// so it can surface e.g. a Cloud console link. Best-effort; never fatal.
const cliClient = args.cliAddr
? new CLIClient(args.cliAddr, serverOptions.agentName, serverOptions.wsURL)
: undefined;
cliClient?.start();

if (args.room) {
server.event.once('worker_registered', () => {
Expand All @@ -53,6 +64,7 @@ const runServer = async (args: CliArgs) => {
logger.error(e);
}
}
cliClient?.close();
await server.close();
logger.debug('worker closed due to SIGINT.');
process.exit(130); // SIGINT exit code
Expand All @@ -67,6 +79,7 @@ const runServer = async (args: CliArgs) => {
logger.error(e);
}
}
cliClient?.close();
await server.close();
logger.debug('worker closed due to SIGTERM.');
process.exit(143); // SIGTERM exit code
Expand All @@ -77,6 +90,8 @@ const runServer = async (args: CliArgs) => {
} catch {
logger.fatal('closing worker due to error.');
process.exit(1);
} finally {
cliClient?.close();
}
};

Expand Down Expand Up @@ -163,6 +178,11 @@ export const runApp = (opts: ServerOptions) => {
.command('dev')
.description('Start the worker in development mode')
.addOption(logLevelOption('debug'))
.addOption(
// Set by `lk agent dev`: address of the CLI's dev channel the agent reports
// ServerInfo to (agent name + URL, e.g. for a Cloud console link).
new Option('--cli-addr <string>', 'Internal use only').hideHelp(),
)
.action((...[, command]) => {
const globalOptions = program.optsWithGlobals();
const commandOptions = command.opts();
Expand All @@ -176,6 +196,7 @@ export const runApp = (opts: ServerOptions) => {
opts,
production: false,
watch: false,
cliAddr: commandOptions.cliAddr,
});
});

Expand Down
84 changes: 84 additions & 0 deletions agents/src/cli_client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { AgentDev } from '@livekit/protocol';
import { type Socket, createConnection } from 'node:net';
import { log } from './log.js';

/**
* Client for the dev channel opened by the driving `lk` CLI (its address is
* passed via `--cli-addr`). On connect it reports this agent server's
* {@link AgentDev.ServerInfo | ServerInfo} (agent name + LiveKit URL) so the CLI
* can surface things like a Cloud console link.
*
* This mirrors the Python `WatchClient`'s ServerInfo handshake. Unlike Python,
* Node reloads are a plain kill+respawn, so there is no running-job capture /
* restore — the client only sends ServerInfo. It is strictly best-effort: a
* missing or broken dev channel must never take the agent down.
*/
export class CLIClient {
#cliAddr: string;
#agentName: string;
#url: string;
#socket?: Socket;

constructor(cliAddr: string, agentName: string, url: string) {
this.#cliAddr = cliAddr;
this.#agentName = agentName;
this.#url = url;
}

start(): void {
const logger = log().child({ cliAddr: this.#cliAddr });

// cli_addr is host:port; the host may itself contain ':' (IPv6), so split on
// the last colon.
const sep = this.#cliAddr.lastIndexOf(':');
if (sep === -1) {
logger.warn('invalid --cli-addr, skipping dev channel');
return;
}
let host = this.#cliAddr.slice(0, sep);
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
const port = Number.parseInt(this.#cliAddr.slice(sep + 1), 10);
if (Number.isNaN(port) || port < 0 || port > 65535) {
logger.warn('invalid port in --cli-addr, skipping dev channel');
return;
}

try {
const socket = createConnection({ host, port }, () => {
const msg = new AgentDev.AgentDevMessage({
message: {
case: 'serverInfo',
value: new AgentDev.ServerInfo({ agentName: this.#agentName, url: this.#url }),
},
});
this.#sendProto(socket, msg.toBinary());
});

// Best-effort: log and move on if the CLI isn't listening.
socket.on('error', (err) => {
logger.debug(`dev channel unavailable: ${err.message}`);
});

this.#socket = socket;
} catch (err) {
logger.debug(`dev channel unavailable: ${(err as Error).message}`);
}
}

close(): void {
this.#socket?.destroy();
this.#socket = undefined;
}

/** Frames a message with a 4-byte big-endian length prefix (matches the Go CLI). */
#sendProto(socket: Socket, payload: Uint8Array): void {
const header = Buffer.allocUnsafe(4);
header.writeUInt32BE(payload.length, 0);
socket.write(Buffer.concat([header, payload]));
}
}
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading