pi-control-bridge 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pi-control-bridge might be problematic. Click here for more details.

@@ -0,0 +1,76 @@
1
+ import { getIpcBaseUrl } from "./ensure_bridge.ts";
2
+ import type {
3
+ BridgeStatus,
4
+ ControlStatus,
5
+ PendingCommand,
6
+ RegisterSessionRequest,
7
+ RegisterSessionResponse,
8
+ SessionEventPayload,
9
+ } from "../shared/types.ts";
10
+ import type { TelegramLinkResponse } from "../shared/telegram.ts";
11
+
12
+ async function ipcRequest<T>(
13
+ path: string,
14
+ init?: RequestInit,
15
+ ): Promise<T | null> {
16
+ const response = await fetch(`${getIpcBaseUrl()}${path}`, init);
17
+ if (response.status === 204) return null;
18
+ if (!response.ok) {
19
+ const text = await response.text();
20
+ throw new Error(`IPC ${path} failed: ${response.status} ${text}`);
21
+ }
22
+ return (await response.json()) as T;
23
+ }
24
+
25
+ export async function registerSession(
26
+ payload: RegisterSessionRequest,
27
+ ): Promise<RegisterSessionResponse> {
28
+ return ipcRequest<RegisterSessionResponse>("/sessions/register", {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify(payload),
32
+ }) as Promise<RegisterSessionResponse>;
33
+ }
34
+
35
+ export async function unregisterSession(localId: string): Promise<void> {
36
+ await ipcRequest(`/sessions/${encodeURIComponent(localId)}`, {
37
+ method: "DELETE",
38
+ });
39
+ }
40
+
41
+ export async function requestBridgeShutdownIfIdle(): Promise<void> {
42
+ await ipcRequest("/shutdown-if-idle", { method: "POST" });
43
+ }
44
+
45
+ export async function postSessionEvent(
46
+ localId: string,
47
+ event: SessionEventPayload,
48
+ ): Promise<void> {
49
+ await ipcRequest(`/sessions/${encodeURIComponent(localId)}/events`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify(event),
53
+ });
54
+ }
55
+
56
+ export async function waitForCommand(localId: string): Promise<PendingCommand | null> {
57
+ return ipcRequest<PendingCommand>(
58
+ `/sessions/${encodeURIComponent(localId)}/commands/wait`,
59
+ );
60
+ }
61
+
62
+ export async function getBridgeStatus(): Promise<BridgeStatus & { version?: string }> {
63
+ return ipcRequest<BridgeStatus & { version?: string }>("/health") as Promise<
64
+ BridgeStatus & { version?: string }
65
+ >;
66
+ }
67
+
68
+ export async function getControlStatus(): Promise<ControlStatus> {
69
+ return ipcRequest<ControlStatus>("/connection-status") as Promise<ControlStatus>;
70
+ }
71
+
72
+ export async function createTelegramLinkToken(): Promise<TelegramLinkResponse> {
73
+ return ipcRequest<TelegramLinkResponse>("/telegram/link-token", {
74
+ method: "POST",
75
+ }) as Promise<TelegramLinkResponse>;
76
+ }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { PendingCommand } from "../shared/types.ts";
4
+
5
+ export function executeCommand(
6
+ pi: ExtensionAPI,
7
+ ctx: ExtensionContext,
8
+ command: PendingCommand,
9
+ ): void {
10
+ switch (command.kind) {
11
+ case "prompt": {
12
+ const text = String(command.payload?.text ?? "");
13
+ if (!text) return;
14
+ if (ctx.isIdle()) {
15
+ pi.sendUserMessage(text);
16
+ } else {
17
+ pi.sendUserMessage(text, { deliverAs: "steer" });
18
+ }
19
+ break;
20
+ }
21
+ case "interrupt":
22
+ ctx.abort();
23
+ break;
24
+ case "stop":
25
+ ctx.shutdown();
26
+ break;
27
+ case "ping":
28
+ break;
29
+ default:
30
+ console.error(
31
+ JSON.stringify({
32
+ level: "WARN",
33
+ message: "Unknown bridge command kind",
34
+ kind: command.kind,
35
+ commandId: command.commandId,
36
+ }),
37
+ );
38
+ }
39
+ }
@@ -0,0 +1,78 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+
6
+ import { ipcBaseUrl, loadBridgeConfig } from "../shared/config.ts";
7
+
8
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
9
+
10
+ let configCwd: string | undefined;
11
+
12
+ /** Pin project cwd for config resolution while a Pi session is active. */
13
+ export function setBridgeConfigCwd(cwd: string | undefined): void {
14
+ configCwd = cwd;
15
+ }
16
+
17
+ function configOptions() {
18
+ return { cwd: configCwd };
19
+ }
20
+
21
+ export function getBridgeConfig() {
22
+ return loadBridgeConfig(configOptions());
23
+ }
24
+
25
+ function resolveBridgeBin(): { bin: string; built: boolean } {
26
+ const built = join(packageRoot, "dist", "bridge", "main.js");
27
+ if (existsSync(built)) return { bin: built, built: true };
28
+ return { bin: join(packageRoot, "bridge", "main.ts"), built: false };
29
+ }
30
+
31
+ export async function isBridgeRunning(): Promise<boolean> {
32
+ const config = getBridgeConfig();
33
+ try {
34
+ const response = await fetch(`${ipcBaseUrl(config.ipcPort)}/health`, {
35
+ signal: AbortSignal.timeout(2000),
36
+ });
37
+ return response.ok;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export async function ensureBridge(): Promise<boolean> {
44
+ if (await isBridgeRunning()) return true;
45
+
46
+ const config = getBridgeConfig();
47
+ if (!config.autoStartBridge) return false;
48
+
49
+ const { bin, built } = resolveBridgeBin();
50
+ if (!built) {
51
+ console.error(
52
+ JSON.stringify({
53
+ level: "ERROR",
54
+ message:
55
+ "pi-control-bridge: dist/bridge/main.js missing. Reinstall the package (npm:pi-control-bridge).",
56
+ packageRoot,
57
+ }),
58
+ );
59
+ return false;
60
+ }
61
+
62
+ const child = spawn(process.execPath, [bin, "start"], {
63
+ detached: true,
64
+ stdio: "ignore",
65
+ env: process.env,
66
+ });
67
+ child.unref();
68
+
69
+ for (let attempt = 0; attempt < 20; attempt += 1) {
70
+ await new Promise((resolve) => setTimeout(resolve, 250));
71
+ if (await isBridgeRunning()) return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ export function getIpcBaseUrl(): string {
77
+ return ipcBaseUrl(getBridgeConfig().ipcPort);
78
+ }
@@ -0,0 +1,291 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ import {
6
+ createTelegramLinkToken,
7
+ getBridgeStatus,
8
+ getControlStatus,
9
+ postSessionEvent,
10
+ registerSession,
11
+ requestBridgeShutdownIfIdle,
12
+ unregisterSession,
13
+ waitForCommand,
14
+ } from "./bridge_client.ts";
15
+ import { executeCommand } from "./command_handler.ts";
16
+ import { ensureBridge, setBridgeConfigCwd } from "./ensure_bridge.ts";
17
+ import { formatConnectTelegramMessage, formatControlStatusMessage } from "./messages.ts";
18
+ import { buildSessionMetadata } from "./session_metadata.ts";
19
+ import { sessionStatusAfterAgentEnd, sessionStatusFromContext } from "./session_status.ts";
20
+
21
+ interface SessionBinding {
22
+ localId: string;
23
+ consumerAbort: AbortController;
24
+ }
25
+
26
+ const sessions = new Map<string, SessionBinding>();
27
+ let degradedNotified = false;
28
+
29
+ function isEphemeral(ctx: ExtensionContext): boolean {
30
+ return ctx.sessionManager.getSessionFile() === undefined;
31
+ }
32
+
33
+ async function postEvent(
34
+ localId: string,
35
+ eventType: string,
36
+ status?: string,
37
+ payload?: Record<string, unknown>,
38
+ ): Promise<void> {
39
+ if (!sessions.has(localId)) return;
40
+ try {
41
+ await postSessionEvent(localId, {
42
+ eventType,
43
+ status,
44
+ payload,
45
+ eventId: randomUUID(),
46
+ });
47
+ } catch (error) {
48
+ console.error(
49
+ JSON.stringify({
50
+ level: "WARN",
51
+ message: "Failed to post bridge event",
52
+ eventType,
53
+ error: String(error),
54
+ }),
55
+ );
56
+ }
57
+ }
58
+
59
+ async function postSessionMetadata(
60
+ pi: ExtensionAPI,
61
+ ctx: ExtensionContext,
62
+ localId: string,
63
+ options?: { messages?: unknown[] },
64
+ ): Promise<void> {
65
+ const payload = buildSessionMetadata(pi, ctx, options);
66
+ if (Object.keys(payload).length === 0) return;
67
+ await postEvent(localId, "session_metadata", undefined, payload);
68
+ }
69
+
70
+ function startCommandConsumer(
71
+ pi: ExtensionAPI,
72
+ ctx: ExtensionContext,
73
+ localId: string,
74
+ signal: AbortSignal,
75
+ ): void {
76
+ void (async () => {
77
+ while (!signal.aborted) {
78
+ try {
79
+ const command = await waitForCommand(localId);
80
+ if (signal.aborted) break;
81
+ if (command) {
82
+ executeCommand(pi, ctx, command);
83
+ }
84
+ } catch (error) {
85
+ if (signal.aborted) break;
86
+ console.error(
87
+ JSON.stringify({
88
+ level: "WARN",
89
+ message: "Command consumer error",
90
+ error: String(error),
91
+ }),
92
+ );
93
+ await new Promise((resolve) => setTimeout(resolve, 1000));
94
+ }
95
+ }
96
+ })();
97
+ }
98
+
99
+ async function handleSessionStart(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
100
+ if (isEphemeral(ctx)) return;
101
+
102
+ setBridgeConfigCwd(ctx.cwd);
103
+
104
+ const ready = await ensureBridge();
105
+ if (!ready) {
106
+ if (ctx.hasUI && !degradedNotified) {
107
+ ctx.ui.notify("Telegram/backend integration unavailable (bridge not running)", "warning");
108
+ degradedNotified = true;
109
+ }
110
+ return;
111
+ }
112
+
113
+ try {
114
+ const status = await getBridgeStatus();
115
+ if (status.degraded && ctx.hasUI && !degradedNotified) {
116
+ ctx.ui.notify("Backend connection degraded; events will be queued locally", "warning");
117
+ degradedNotified = true;
118
+ }
119
+ } catch {
120
+ if (ctx.hasUI && !degradedNotified) {
121
+ ctx.ui.notify("Bridge unreachable", "warning");
122
+ degradedNotified = true;
123
+ }
124
+ }
125
+
126
+ const localId = ctx.sessionManager.getSessionId();
127
+ const consumerAbort = new AbortController();
128
+ const status = sessionStatusFromContext(ctx);
129
+
130
+ try {
131
+ await registerSession({
132
+ localId,
133
+ externalSessionId: localId,
134
+ cwd: ctx.cwd,
135
+ projectPath: ctx.cwd,
136
+ title: pi.getSessionName(),
137
+ pid: process.pid,
138
+ mode: ctx.mode,
139
+ status,
140
+ });
141
+ } catch (error) {
142
+ console.error(
143
+ JSON.stringify({
144
+ level: "ERROR",
145
+ message: "Session bridge registration failed",
146
+ error: String(error),
147
+ }),
148
+ );
149
+ return;
150
+ }
151
+
152
+ sessions.set(localId, { localId, consumerAbort });
153
+ startCommandConsumer(pi, ctx, localId, consumerAbort.signal);
154
+ await postEvent(localId, "session_start", status);
155
+ await postSessionMetadata(pi, ctx, localId);
156
+ }
157
+
158
+ async function handleSessionShutdown(ctx: ExtensionContext): Promise<void> {
159
+ const localId = ctx.sessionManager.getSessionId();
160
+ const binding = sessions.get(localId);
161
+
162
+ if (binding) {
163
+ binding.consumerAbort.abort();
164
+ sessions.delete(localId);
165
+
166
+ try {
167
+ await unregisterSession(localId);
168
+ } catch (error) {
169
+ console.error(
170
+ JSON.stringify({
171
+ level: "WARN",
172
+ message: "Session bridge unregister failed",
173
+ error: String(error),
174
+ }),
175
+ );
176
+ }
177
+ }
178
+
179
+ if (sessions.size > 0) return;
180
+
181
+ setBridgeConfigCwd(undefined);
182
+ try {
183
+ await requestBridgeShutdownIfIdle();
184
+ } catch (error) {
185
+ console.error(
186
+ JSON.stringify({
187
+ level: "WARN",
188
+ message: "Bridge idle shutdown request failed",
189
+ error: String(error),
190
+ }),
191
+ );
192
+ }
193
+ }
194
+
195
+ export function registerHooks(pi: ExtensionAPI): void {
196
+ pi.on("session_start", (_event, ctx) =>
197
+ handleSessionStart(pi, ctx).catch((error) => {
198
+ console.error(
199
+ JSON.stringify({
200
+ level: "ERROR",
201
+ message: "session_start bridge handler failed",
202
+ error: String(error),
203
+ }),
204
+ );
205
+ }),
206
+ );
207
+
208
+ pi.on("session_shutdown", (_event, ctx) =>
209
+ handleSessionShutdown(ctx).catch((error) => {
210
+ console.error(
211
+ JSON.stringify({
212
+ level: "ERROR",
213
+ message: "session_shutdown bridge handler failed",
214
+ error: String(error),
215
+ }),
216
+ );
217
+ }),
218
+ );
219
+
220
+ pi.on("agent_start", (_event, ctx) => {
221
+ void postEvent(ctx.sessionManager.getSessionId(), "agent_start", "running");
222
+ });
223
+
224
+ pi.on("tool_execution_start", (event, ctx) => {
225
+ void postEvent(ctx.sessionManager.getSessionId(), "tool_execution_start", "running", {
226
+ toolName: event.toolName,
227
+ });
228
+ });
229
+
230
+ pi.on("tool_execution_end", (event, ctx) => {
231
+ void postEvent(ctx.sessionManager.getSessionId(), "tool_execution_end", "running", {
232
+ toolName: event.toolName,
233
+ });
234
+ });
235
+
236
+ pi.on("agent_end", (event, ctx) => {
237
+ const localId = ctx.sessionManager.getSessionId();
238
+ const status = sessionStatusAfterAgentEnd(ctx);
239
+ void postEvent(localId, "agent_end", status);
240
+ void postSessionMetadata(pi, ctx, localId, { messages: event.messages });
241
+ });
242
+
243
+ pi.on("turn_end", (event, ctx) => {
244
+ const localId = ctx.sessionManager.getSessionId();
245
+ void postSessionMetadata(pi, ctx, localId, { messages: [event.message] });
246
+ });
247
+
248
+ pi.registerCommand("control-status", {
249
+ description: "Show pi-control-bridge and Telegram connection status",
250
+ handler: async (_args, ctx) => {
251
+ try {
252
+ const ready = await ensureBridge();
253
+ if (!ready) {
254
+ const message = "Bridge is not running. Start a Pi session or run pi-bridge start.";
255
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
256
+ else console.error(message);
257
+ return;
258
+ }
259
+
260
+ const status = await getControlStatus();
261
+ const message = formatControlStatusMessage(status);
262
+ if (ctx.hasUI) ctx.ui.notify(message, "info");
263
+ else console.error(message);
264
+ } catch (error) {
265
+ if (ctx.hasUI) ctx.ui.notify(`Control status error: ${String(error)}`, "error");
266
+ }
267
+ },
268
+ });
269
+
270
+ pi.registerCommand("connect-telegram", {
271
+ description: "Initialize Telegram control for this device",
272
+ handler: async (_args, ctx) => {
273
+ try {
274
+ const ready = await ensureBridge();
275
+ if (!ready) {
276
+ const message = "Bridge is not running. Start a Pi session or run pi-bridge start.";
277
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
278
+ else console.error(message);
279
+ return;
280
+ }
281
+
282
+ const link = await createTelegramLinkToken();
283
+ const message = formatConnectTelegramMessage(link);
284
+ if (ctx.hasUI) ctx.ui.notify(message, "info");
285
+ else console.error(message);
286
+ } catch (error) {
287
+ if (ctx.hasUI) ctx.ui.notify(`Telegram connect error: ${String(error)}`, "error");
288
+ }
289
+ },
290
+ });
291
+ }
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { registerHooks } from "./hooks.ts";
4
+
5
+ export default function piControlBridgeExtension(pi: ExtensionAPI): void {
6
+ registerHooks(pi);
7
+ }
@@ -0,0 +1,132 @@
1
+ import { ansi } from "../shared/ansi.ts";
2
+ import { getSystemLocale, pluralMinutes, type AppLocale } from "../shared/locale.ts";
3
+ import type { ControlStatus } from "../shared/types.ts";
4
+ import type { TelegramLinkResponse } from "../shared/telegram.ts";
5
+
6
+ const connectTelegramCopy = {
7
+ ru: {
8
+ title: "Подключение Telegram",
9
+ alreadyConnectedTitle: "Telegram уже подключён",
10
+ alreadyConnectedAccount: (username: string) => `Аккаунт: ${username}`,
11
+ openLink: "Откройте ссылку в Telegram:",
12
+ openBot: "Откройте бота в Telegram:",
13
+ sendCommand: (token: string) => `Отправьте команду: /start ${token}`,
14
+ token: (token: string) => `Токен: ${token}`,
15
+ sendInBot: "Отправьте в боте: /start <token>",
16
+ validFor: (minutes: number, until: string) =>
17
+ `Действует ${minutes} ${pluralMinutes(minutes, "ru")}, до ${until}`,
18
+ },
19
+ en: {
20
+ title: "Connect Telegram",
21
+ alreadyConnectedTitle: "Telegram already connected",
22
+ alreadyConnectedAccount: (username: string) => `Account: ${username}`,
23
+ openLink: "Open this link in Telegram:",
24
+ openBot: "Open the bot in Telegram:",
25
+ sendCommand: (token: string) => `Send the command: /start ${token}`,
26
+ token: (token: string) => `Token: ${token}`,
27
+ sendInBot: "Send in the bot: /start <token>",
28
+ validFor: (minutes: number, until: string) =>
29
+ `Valid for ${minutes} ${pluralMinutes(minutes, "en")}, until ${until}`,
30
+ },
31
+ } as const;
32
+
33
+ export interface FormatConnectTelegramOptions {
34
+ locale?: AppLocale;
35
+ now?: Date;
36
+ }
37
+
38
+ export function formatExpiryLine(
39
+ expiresAt: string,
40
+ locale: AppLocale,
41
+ now: Date = new Date(),
42
+ ): string | undefined {
43
+ const expires = new Date(expiresAt);
44
+ if (Number.isNaN(expires.getTime())) return undefined;
45
+
46
+ const diffMs = Math.max(0, expires.getTime() - now.getTime());
47
+ const minutes = Math.max(1, Math.round(diffMs / 60_000));
48
+ const until = new Intl.DateTimeFormat(locale === "ru" ? "ru-RU" : "en-GB", {
49
+ year: "numeric",
50
+ month: "2-digit",
51
+ day: "2-digit",
52
+ hour: "2-digit",
53
+ minute: "2-digit",
54
+ second: "2-digit",
55
+ hour12: false,
56
+ }).format(expires);
57
+
58
+ return connectTelegramCopy[locale].validFor(minutes, until);
59
+ }
60
+
61
+ export function formatControlStatusMessage(status: ControlStatus): string {
62
+ const lines = [
63
+ "pi-control-bridge",
64
+ "",
65
+ `device: ${status.deviceId ?? "n/a"}`,
66
+ `sessions: ${status.activeSessions}`,
67
+ `backend: ${status.backendConnected ? "ok" : "degraded"}`,
68
+ `telegram: ${
69
+ status.telegram.linked
70
+ ? `linked${status.telegram.username ? ` (${status.telegram.username})` : ""}`
71
+ : "not linked"
72
+ }`,
73
+ ];
74
+
75
+ if (status.bot.username || status.bot.link) {
76
+ const botLabel = status.bot.username ? `@${status.bot.username.replace(/^@/, "")}` : "bot";
77
+ lines.push(`bot: ${status.bot.link ? `${botLabel} — ${status.bot.link}` : botLabel}`);
78
+ }
79
+
80
+ lines.push(`pending events: ${status.pendingEvents}`);
81
+ return lines.join("\n");
82
+ }
83
+
84
+ export function formatConnectTelegramMessage(
85
+ link: TelegramLinkResponse,
86
+ options: FormatConnectTelegramOptions = {},
87
+ ): string {
88
+ const locale = options.locale ?? getSystemLocale();
89
+ const copy = connectTelegramCopy[locale];
90
+
91
+ if (link.alreadyLinked) {
92
+ const lines = [ansi.title(copy.alreadyConnectedTitle), ""];
93
+ if (link.telegramUsername) {
94
+ lines.push(ansi.label(copy.alreadyConnectedAccount(link.telegramUsername)), "");
95
+ }
96
+
97
+ const botUrl =
98
+ link.botLink ??
99
+ (link.botUsername ? `https://t.me/${link.botUsername.replace(/^@/, "")}` : undefined);
100
+ if (botUrl) {
101
+ lines.push(ansi.label(copy.openBot), ansi.link(botUrl));
102
+ }
103
+
104
+ return lines.join("\n");
105
+ }
106
+
107
+ const lines = [ansi.title(copy.title), ""];
108
+
109
+ if (link.botLink) {
110
+ lines.push(ansi.label(copy.openLink), ansi.link(link.botLink), "");
111
+ } else if (link.botUsername) {
112
+ const botUrl = `https://t.me/${link.botUsername.replace(/^@/, "")}`;
113
+ lines.push(
114
+ ansi.label(copy.openBot),
115
+ ansi.link(botUrl),
116
+ "",
117
+ ansi.label(copy.sendCommand(link.token)),
118
+ "",
119
+ );
120
+ } else {
121
+ lines.push(ansi.label(copy.token(link.token)), "", ansi.label(copy.sendInBot), "");
122
+ }
123
+
124
+ if (link.expiresAt) {
125
+ const expiryLine = formatExpiryLine(link.expiresAt, locale, options.now);
126
+ if (expiryLine) {
127
+ lines.push(ansi.accent(expiryLine));
128
+ }
129
+ }
130
+
131
+ return lines.join("\n");
132
+ }