feat: add --remote-control flag, status indicator, and fix config check ordering

This commit is contained in:
Yejun Su 2026-03-19 12:10:39 +08:00
parent 18f49a6828
commit ee3341d20c
No known key found for this signature in database
GPG Key ID: AD03A563F321CA44
1 changed files with 58 additions and 7 deletions

View File

@ -892,11 +892,13 @@ interface RemoteServer {
broadcast: (msg: object) => void; broadcast: (msg: object) => void;
stop: () => Promise<void>; stop: () => Promise<void>;
clientCount: () => number; clientCount: () => number;
onClientChange: (cb: () => void) => void;
port: number; port: number;
token: string; token: string;
} }
function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> { function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> {
const clientChangeListeners: Array<() => void> = [];
const clients = new Set<any>(); const clients = new Set<any>();
const token = generateToken(); const token = generateToken();
// Map of valid session IDs → expiry timestamp (ms since epoch) // Map of valid session IDs → expiry timestamp (ms since epoch)
@ -1015,6 +1017,7 @@ function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteSer
wss.on("connection", (ws: any) => { wss.on("connection", (ws: any) => {
clients.add(ws); clients.add(ws);
for (const cb of clientChangeListeners) cb();
// Send full state snapshot to the new client // Send full state snapshot to the new client
try { try {
@ -1067,6 +1070,7 @@ function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteSer
const onClose = () => { const onClose = () => {
clients.delete(ws); clients.delete(ws);
broadcast({ type: "status", clientCount: clients.size }); broadcast({ type: "status", clientCount: clients.size });
for (const cb of clientChangeListeners) cb();
}; };
ws.on("close", onClose); ws.on("close", onClose);
ws.on("error", onClose); ws.on("error", onClose);
@ -1088,6 +1092,7 @@ function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteSer
wss.close(() => httpServer.close(() => res())); wss.close(() => httpServer.close(() => res()));
}), }),
clientCount: () => clients.size, clientCount: () => clients.size,
onClientChange: (cb: () => void) => { clientChangeListeners.push(cb); },
get port() { get port() {
return (httpServer.address() as any)?.port ?? 0; return (httpServer.address() as any)?.port ?? 0;
}, },
@ -1104,8 +1109,50 @@ function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteSer
export default function remoteControl(pi: ExtensionAPI) { export default function remoteControl(pi: ExtensionAPI) {
let server: RemoteServer | undefined; let server: RemoteServer | undefined;
// ── CLI flag ──────────────────────────────────────────────────────────────
pi.registerFlag("remote-control", {
description: "Start the remote-control server automatically on session start",
type: "boolean",
default: false,
});
// ── Status indicator ──────────────────────────────────────────────────────
function updateStatus(ctx: ExtensionContext): void {
if (!ctx.hasUI || !server) return;
const clients = server.clientCount();
const label = clients > 0 ? `remote:${clients}` : "remote:on";
ctx.ui.setStatus("remote-control", ctx.ui.theme.fg("accent", label));
}
// ── Lifecycle ────────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("remote-control") !== true) return;
const config = await readRemoteControlConfig();
const publicBaseUrl = config.publicBaseUrl?.trim();
if (!publicBaseUrl) {
if (ctx.hasUI) {
ctx.ui.notify(
"--remote-control: no publicBaseUrl configured. Run /remote-control config first.",
"warning",
);
}
return;
}
server = await startServer(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
if (ctx.hasUI) {
ctx.ui.notify(`Remote-control started: ${url}`, "info");
}
updateStatus(ctx);
});
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
if (server) { if (server) {
await server.stop(); await server.stop();
@ -1115,12 +1162,14 @@ export default function remoteControl(pi: ExtensionAPI) {
// ── Event bridge: pi → clients ──────────────────────────────────────────── // ── Event bridge: pi → clients ────────────────────────────────────────────
pi.on("agent_start", async () => { pi.on("agent_start", async (_event, ctx) => {
server?.broadcast({ type: "agent_start" }); server?.broadcast({ type: "agent_start" });
updateStatus(ctx);
}); });
pi.on("agent_end", async () => { pi.on("agent_end", async (_event, ctx) => {
server?.broadcast({ type: "agent_end" }); server?.broadcast({ type: "agent_end" });
updateStatus(ctx);
}); });
pi.on("message_update", async (event) => { pi.on("message_update", async (event) => {
@ -1176,17 +1225,19 @@ export default function remoteControl(pi: ExtensionAPI) {
return; return;
} }
// Start server on first invocation
if (!server) {
server = await startServer(pi, ctx);
}
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const publicBaseUrl = config.publicBaseUrl?.trim(); const publicBaseUrl = config.publicBaseUrl?.trim();
if (!publicBaseUrl) { if (!publicBaseUrl) {
ctx.ui.notify("Set the public URL first with /remote-control config", "warning"); ctx.ui.notify("Set the public URL first with /remote-control config", "warning");
return; return;
} }
// Start server on first invocation
if (!server) {
server = await startServer(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
updateStatus(ctx);
}
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
// Generate QR code // Generate QR code