Files
agentic-chat-connect/src/connectLocalController.ts
Chengdong Zhang 94721227c0 feat(connect-local): add Pi extension for local agent bridge
Monorepo Pi package with outbound WebSocket, pairing/reconnect flow,
presence reporting, and single-flight task.dispatch handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 16:22:41 +08:00

240 lines
7.4 KiB
TypeScript

import os from 'node:os';
import path from 'node:path';
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
import type { LocalAgentServerMessage } from '@pi-platform/shared';
import {
clearReconnectCredential,
loadReconnectCredential,
saveReconnectCredential,
} from './credentialStore.js';
import { LocalAgentWsClient } from './wsClient.js';
export interface ConnectLocalControllerDeps {
pi: ExtensionAPI;
credentialBaseDir?: string;
wsUrl?: string;
createWsClient?: (options: ConstructorParameters<typeof LocalAgentWsClient>[0]) => LocalAgentWsClient;
}
export class ConnectLocalController {
private readonly pi: ExtensionAPI;
private readonly credentialBaseDir: string;
private readonly wsUrl: string;
private readonly createWsClient: ConnectLocalControllerDeps['createWsClient'];
private client: LocalAgentWsClient | undefined;
private projectName: string | undefined;
private connectionEpoch: number | undefined;
private remoteTaskReserved = false;
private agentBusy = false;
private pairWaiters: Array<{
resolve: (value: LocalAgentServerMessage & { type: 'pair.ok' }) => void;
reject: (error: Error) => void;
}> = [];
constructor(deps: ConnectLocalControllerDeps) {
this.pi = deps.pi;
this.credentialBaseDir = deps.credentialBaseDir ?? path.join(os.homedir(), '.pi-platform', 'connect-local');
this.wsUrl = deps.wsUrl ?? process.env.PI_PLATFORM_LOCAL_AGENT_WS_URL ?? 'ws://127.0.0.1:3000/api/local-agent/ws';
this.createWsClient = deps.createWsClient ?? ((options) => new LocalAgentWsClient(options));
}
registerCommands(): void {
this.pi.registerCommand('connect', {
description: 'Pair this interactive Pi session with the cloud platform',
handler: async (args, ctx) => {
await this.handleConnect(args, ctx);
},
});
this.pi.registerCommand('disconnect', {
description: 'Disconnect the local agent bridge',
handler: async (_args, ctx) => {
await this.handleDisconnect(ctx);
},
});
this.pi.registerCommand('connected', {
description: 'Show local agent bridge connection status',
handler: async (_args, ctx) => {
this.notifyStatus(ctx);
},
});
}
bindLifecycle(): void {
this.pi.on('agent_start', async (_event, ctx) => {
this.agentBusy = true;
this.sendPresence('busy', ctx);
});
this.pi.on('agent_settled', async (_event, ctx) => {
this.agentBusy = false;
this.remoteTaskReserved = false;
this.sendPresence('idle', ctx);
});
this.pi.on('session_shutdown', async () => {
this.shutdown();
});
}
scheduleAutoReconnect(): void {
queueMicrotask(() => {
void this.tryAutoReconnect();
});
}
async handleConnect(args: string, ctx: ExtensionCommandContext): Promise<void> {
const parts = args.trim().split(/\s+/).filter(Boolean);
const token = parts[0];
if (!token) {
ctx.ui.notify('Usage: /connect <pairing-token> [project-name]', 'warning');
return;
}
const projectName = parts[1] ?? path.basename(ctx.cwd);
try {
await this.connectWithPairingToken(token, projectName);
ctx.ui.notify(`Connected local project "${projectName}"`, 'info');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Connect failed: ${message}`, 'error');
}
}
async handleDisconnect(ctx: ExtensionCommandContext): Promise<void> {
this.shutdown();
await clearReconnectCredential(this.credentialBaseDir);
ctx.ui.notify('Disconnected from cloud platform', 'info');
}
notifyStatus(ctx: ExtensionCommandContext): void {
if (!this.client?.isOpen || !this.projectName) {
ctx.ui.notify('Local agent: offline', 'info');
return;
}
ctx.ui.notify(`Local agent: online (${this.projectName})`, 'info');
}
async connectWithPairingToken(token: string, projectName: string): Promise<void> {
await this.ensureClient();
const pairOk = await new Promise<LocalAgentServerMessage & { type: 'pair.ok' }>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Pairing timed out')), 30_000);
this.pairWaiters.push({
resolve: (value) => {
clearTimeout(timeout);
resolve(value);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
});
try {
this.client!.send({ type: 'pair', token, projectName });
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
this.projectName = pairOk.projectName;
this.connectionEpoch = pairOk.connectionEpoch;
await saveReconnectCredential(this.credentialBaseDir, {
projectName: pairOk.projectName,
credential: pairOk.reconnectCredential,
wsUrl: this.wsUrl,
updatedAt: new Date().toISOString(),
});
}
async tryAutoReconnect(): Promise<void> {
const stored = await loadReconnectCredential(this.credentialBaseDir);
if (!stored?.credential || !stored.projectName) {
return;
}
try {
await this.ensureClient(stored.wsUrl || this.wsUrl);
this.client!.send({
type: 'reconnect',
credential: stored.credential,
projectName: stored.projectName,
connectionEpoch: this.connectionEpoch,
});
this.projectName = stored.projectName;
} catch {
/* extension must not block Pi; reconnect can be retried manually */
}
}
shutdown(): void {
this.client?.close();
this.client = undefined;
this.projectName = undefined;
this.connectionEpoch = undefined;
this.remoteTaskReserved = false;
}
private async ensureClient(wsUrl = this.wsUrl): Promise<void> {
if (this.client?.isOpen) {
return;
}
this.client = this.createWsClient!({
wsUrl,
onServerMessage: (message) => this.handleServerMessage(message),
onClose: () => {
this.projectName = undefined;
},
});
await this.client.connect();
}
private handleServerMessage(message: LocalAgentServerMessage): void {
if (message.type === 'pair.ok') {
const waiter = this.pairWaiters.shift();
waiter?.resolve(message);
return;
}
if (message.type === 'error') {
const waiter = this.pairWaiters.shift();
waiter?.reject(new Error(message.message));
return;
}
if (message.type === 'task.dispatch') {
void this.handleTaskDispatch(message);
}
}
private handleTaskDispatch(message: Extract<LocalAgentServerMessage, { type: 'task.dispatch' }>): void {
if (!this.client?.isOpen) {
return;
}
if (this.remoteTaskReserved || this.agentBusy) {
this.client.send({
type: 'task.result',
taskId: message.taskId,
status: 'rejected',
reason: 'agent_busy',
});
return;
}
this.remoteTaskReserved = true;
this.client.send({ type: 'presence', status: 'busy' });
this.pi.sendUserMessage(message.prompt);
this.client.send({
type: 'task.result',
taskId: message.taskId,
status: 'accepted',
});
}
private sendPresence(status: 'idle' | 'busy', ctx: ExtensionContext): void {
if (!this.client?.isOpen) {
return;
}
if (status === 'idle' && this.remoteTaskReserved) {
return;
}
if (!ctx.isIdle() && status === 'idle') {
return;
}
this.client.send({ type: 'presence', status });
}
}