@llblab/pi-telegram 0.3.0 → 0.4.0

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.
package/lib/locks.ts ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Telegram singleton lock helpers
3
+ * Owns shared locks.json access and Telegram bridge ownership semantics
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+
10
+ export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
11
+
12
+ function getAgentDir(): string {
13
+ return process.env.PI_CODING_AGENT_DIR
14
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function getLocksPath(): string {
19
+ return join(getAgentDir(), "locks.json");
20
+ }
21
+
22
+ export interface TelegramLockEntry {
23
+ pid: number;
24
+ cwd?: string;
25
+ }
26
+
27
+ export interface TelegramLockContext {
28
+ cwd: string;
29
+ }
30
+
31
+ export type TelegramLockState =
32
+ | { kind: "inactive" }
33
+ | { kind: "active-here"; lock: TelegramLockEntry }
34
+ | { kind: "active-elsewhere"; lock: TelegramLockEntry }
35
+ | { kind: "stale"; lock: TelegramLockEntry };
36
+
37
+ export interface TelegramLockAcquireOptions {
38
+ force?: boolean;
39
+ }
40
+
41
+ export type TelegramLockAcquireResult =
42
+ | { ok: true; lock: TelegramLockEntry; replacedStale: boolean }
43
+ | { ok: false; lock: TelegramLockEntry };
44
+
45
+ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
46
+ acquire: (
47
+ ctx: TContext,
48
+ options?: TelegramLockAcquireOptions,
49
+ ) => TelegramLockAcquireResult;
50
+ release: () => TelegramLockState;
51
+ getState: () => TelegramLockState;
52
+ getStatusLabel: () => string;
53
+ owns: (ctx?: TelegramLockContext) => boolean;
54
+ }
55
+
56
+ export interface TelegramLockRuntimeOptions {
57
+ key?: string;
58
+ locksPath?: string;
59
+ pid?: number;
60
+ isProcessAlive?: (pid: number) => boolean;
61
+ }
62
+
63
+ export interface TelegramLockedPollingStartOptions {
64
+ force?: boolean;
65
+ }
66
+
67
+ export type TelegramLockedPollingStartResult =
68
+ | { ok: true; message: string; canTakeover?: false }
69
+ | { ok: false; message: string; canTakeover?: boolean; owner?: string };
70
+
71
+ export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
72
+ start: (
73
+ ctx: TContext,
74
+ options?: TelegramLockedPollingStartOptions,
75
+ ) => Promise<TelegramLockedPollingStartResult>;
76
+ stop: () => Promise<string>;
77
+ suspend: () => Promise<void>;
78
+ onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
79
+ }
80
+
81
+ export interface TelegramLockedPollingRuntimeDeps<TContext extends TelegramLockContext> {
82
+ lock: TelegramLockRuntime<TContext>;
83
+ hasBotToken: () => boolean;
84
+ startPolling: (ctx: TContext) => void | Promise<void>;
85
+ stopPolling: () => Promise<void>;
86
+ updateStatus: (ctx: TContext) => void;
87
+ recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
88
+ ownershipCheckMs?: number;
89
+ }
90
+
91
+ export function readLocks(path = getLocksPath()): Record<string, unknown> {
92
+ if (!existsSync(path)) return {};
93
+ try {
94
+ const value = JSON.parse(readFileSync(path, "utf8"));
95
+ return value && typeof value === "object" && !Array.isArray(value)
96
+ ? (value as Record<string, unknown>)
97
+ : {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ export function writeLocks(path: string, locks: Record<string, unknown>): void {
104
+ mkdirSync(dirname(path), { recursive: true });
105
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
106
+ try {
107
+ writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
108
+ renameSync(tempPath, path);
109
+ } catch (error) {
110
+ try {
111
+ unlinkSync(tempPath);
112
+ } catch {
113
+ /* best effort */
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export function parseTelegramLockEntry(value: unknown): TelegramLockEntry | undefined {
120
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
121
+ const record = value as Record<string, unknown>;
122
+ if (typeof record.pid !== "number") return undefined;
123
+ return {
124
+ pid: record.pid,
125
+ cwd: typeof record.cwd === "string" ? record.cwd : undefined,
126
+ };
127
+ }
128
+
129
+ export function isProcessAlive(pid: number): boolean {
130
+ if (!Number.isInteger(pid) || pid <= 0) return false;
131
+ try {
132
+ process.kill(pid, 0);
133
+ return true;
134
+ } catch (error) {
135
+ return (error as { code?: string }).code === "EPERM";
136
+ }
137
+ }
138
+
139
+ function formatLock(lock: TelegramLockEntry): string {
140
+ return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
141
+ }
142
+
143
+ function getLockState(lock: TelegramLockEntry | undefined, pid: number, isAlive: (pid: number) => boolean): TelegramLockState {
144
+ if (!lock) return { kind: "inactive" };
145
+ if (lock.pid === pid) return { kind: "active-here", lock };
146
+ if (isAlive(lock.pid)) return { kind: "active-elsewhere", lock };
147
+ return { kind: "stale", lock };
148
+ }
149
+
150
+ function ownsLockContext(
151
+ lock: TelegramLockEntry | undefined,
152
+ pid: number,
153
+ ctx?: TelegramLockContext,
154
+ ): boolean {
155
+ if (!lock || lock.pid !== pid) return false;
156
+ return !lock.cwd || !ctx || lock.cwd === ctx.cwd;
157
+ }
158
+
159
+ function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
160
+ return { cwd: ctx.cwd };
161
+ }
162
+
163
+ function formatLockState(state: TelegramLockState): string {
164
+ switch (state.kind) {
165
+ case "inactive":
166
+ return "inactive";
167
+ case "active-here":
168
+ return "active here";
169
+ case "active-elsewhere":
170
+ return `active elsewhere (${formatLock(state.lock)})`;
171
+ case "stale":
172
+ return `stale (${formatLock(state.lock)})`;
173
+ }
174
+ }
175
+
176
+ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
177
+ options: TelegramLockRuntimeOptions = {},
178
+ ): TelegramLockRuntime<TContext> {
179
+ const key = options.key ?? TELEGRAM_LOCK_KEY;
180
+ const locksPath = options.locksPath ?? getLocksPath();
181
+ const pid = options.pid ?? process.pid;
182
+ const isAlive = options.isProcessAlive ?? isProcessAlive;
183
+ const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
184
+ const writeLock = (lock: TelegramLockEntry) => {
185
+ const locks = readLocks(locksPath);
186
+ locks[key] = lock;
187
+ writeLocks(locksPath, locks);
188
+ };
189
+ return {
190
+ acquire: (ctx, acquireOptions = {}) => {
191
+ const state = getLockState(readLock(), pid, isAlive);
192
+ if (state.kind === "active-elsewhere" && !acquireOptions.force)
193
+ return { ok: false, lock: state.lock };
194
+ const lock = { pid, cwd: ctx.cwd };
195
+ writeLock(lock);
196
+ return { ok: true, lock, replacedStale: state.kind === "stale" };
197
+ },
198
+ release: () => {
199
+ const state = getLockState(readLock(), pid, isAlive);
200
+ if (state.kind === "active-here" || state.kind === "stale") {
201
+ const locks = readLocks(locksPath);
202
+ delete locks[key];
203
+ writeLocks(locksPath, locks);
204
+ }
205
+ return state;
206
+ },
207
+ getState: () => getLockState(readLock(), pid, isAlive),
208
+ getStatusLabel: () => formatLockState(getLockState(readLock(), pid, isAlive)),
209
+ owns: (ctx) => ownsLockContext(readLock(), pid, ctx),
210
+ };
211
+ }
212
+
213
+ export function createTelegramLockedPollingRuntime<TContext extends TelegramLockContext>(
214
+ deps: TelegramLockedPollingRuntimeDeps<TContext>,
215
+ ): TelegramLockedPollingRuntime<TContext> {
216
+ let ownershipInterval: ReturnType<typeof setInterval> | undefined;
217
+ let ownershipStop: Promise<void> | undefined;
218
+ const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
219
+ const stopOwnershipWatcher = () => {
220
+ if (!ownershipInterval) return;
221
+ clearInterval(ownershipInterval);
222
+ ownershipInterval = undefined;
223
+ };
224
+ const updateStatusSafely = (ctx: TContext, phase: string) => {
225
+ try {
226
+ deps.updateStatus(ctx);
227
+ } catch (error) {
228
+ deps.recordRuntimeEvent?.("lock", error, { phase });
229
+ }
230
+ };
231
+ const suspendPolling = async () => {
232
+ stopOwnershipWatcher();
233
+ if (ownershipStop) {
234
+ await ownershipStop;
235
+ return;
236
+ }
237
+ await deps.stopPolling();
238
+ };
239
+ const stopAfterOwnershipLoss = (ctx: TContext) => {
240
+ if (ownershipStop) return;
241
+ stopOwnershipWatcher();
242
+ ownershipStop = deps.stopPolling()
243
+ .catch((error) => deps.recordRuntimeEvent?.("lock", error, { phase: "ownership-loss" }))
244
+ .finally(() => {
245
+ ownershipStop = undefined;
246
+ updateStatusSafely(ctx, "ownership-loss-status");
247
+ });
248
+ };
249
+ const startOwnershipWatcher = (ctx: TContext) => {
250
+ const owner = snapshotLockContext(ctx);
251
+ stopOwnershipWatcher();
252
+ ownershipInterval = setInterval(() => {
253
+ if (deps.lock.owns(owner)) return;
254
+ stopAfterOwnershipLoss(ctx);
255
+ }, ownershipCheckMs);
256
+ ownershipInterval.unref?.();
257
+ };
258
+ return {
259
+ start: async (ctx, options = {}) => {
260
+ if (!deps.hasBotToken()) return { ok: false, message: "Telegram bot is not configured." };
261
+ const acquired = deps.lock.acquire(ctx, options);
262
+ if (!acquired.ok) {
263
+ return {
264
+ ok: false,
265
+ canTakeover: true,
266
+ owner: formatLock(acquired.lock),
267
+ message: `Telegram bridge is active in another pi instance (${formatLock(acquired.lock)}).`,
268
+ };
269
+ }
270
+ await deps.startPolling(ctx);
271
+ startOwnershipWatcher(ctx);
272
+ deps.updateStatus(ctx);
273
+ const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
274
+ return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
275
+ },
276
+ stop: async () => {
277
+ await suspendPolling();
278
+ const state = deps.lock.release();
279
+ if (state.kind === "active-elsewhere") {
280
+ return `Telegram bridge is active in another pi instance (${formatLock(state.lock)}).`;
281
+ }
282
+ if (state.kind === "stale") return `Removed stale Telegram bridge lock (${formatLock(state.lock)}).`;
283
+ return "Telegram bridge disconnected.";
284
+ },
285
+ suspend: suspendPolling,
286
+ onSessionStart: async (_event, ctx) => {
287
+ if (!deps.hasBotToken()) return;
288
+ const ownsCurrentLock = deps.lock.owns(ctx);
289
+ const state = ownsCurrentLock ? undefined : deps.lock.getState();
290
+ const canResumeStaleSameCwd =
291
+ state?.kind === "stale" && state.lock.cwd === ctx.cwd;
292
+ if (!ownsCurrentLock && !canResumeStaleSameCwd) return;
293
+ try {
294
+ if (canResumeStaleSameCwd) {
295
+ const acquired = deps.lock.acquire(ctx);
296
+ if (!acquired.ok) return;
297
+ }
298
+ await deps.startPolling(ctx);
299
+ startOwnershipWatcher(ctx);
300
+ deps.updateStatus(ctx);
301
+ } catch (error) {
302
+ deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
303
+ }
304
+ },
305
+ };
306
+ }
package/lib/media.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  * Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
4
4
  */
5
5
 
6
+ import { basename, dirname } from "node:path";
7
+
6
8
  const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
7
9
 
8
10
  export interface TelegramPhotoSize {
@@ -89,10 +91,20 @@ export interface TelegramMediaGroupControllerOptions {
89
91
  clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
90
92
  }
91
93
 
94
+ export type TelegramAttachmentKind =
95
+ | "photo"
96
+ | "document"
97
+ | "video"
98
+ | "audio"
99
+ | "voice"
100
+ | "animation"
101
+ | "sticker";
102
+
92
103
  export interface TelegramFileInfo {
93
104
  file_id: string;
94
105
  fileName: string;
95
106
  mimeType?: string;
107
+ kind: TelegramAttachmentKind;
96
108
  isImage: boolean;
97
109
  }
98
110
 
@@ -101,6 +113,7 @@ export interface DownloadedTelegramFile {
101
113
  fileName?: string;
102
114
  isImage?: boolean;
103
115
  mimeType?: string;
116
+ kind?: TelegramAttachmentKind;
104
117
  }
105
118
 
106
119
  export interface DownloadedTelegramMessageFile {
@@ -108,6 +121,7 @@ export interface DownloadedTelegramMessageFile {
108
121
  fileName: string;
109
122
  isImage: boolean;
110
123
  mimeType?: string;
124
+ kind?: TelegramAttachmentKind;
111
125
  }
112
126
 
113
127
  export interface DownloadTelegramMessageFilesDeps {
@@ -281,17 +295,39 @@ export function createTelegramMediaGroupDispatchRuntime<
281
295
  };
282
296
  }
283
297
 
298
+ function appendTelegramListSection(
299
+ text: string,
300
+ title: string,
301
+ items: string[],
302
+ ): string {
303
+ if (items.length === 0) return text;
304
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
305
+ return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
306
+ }
307
+
308
+ function appendTelegramAttachmentSection(
309
+ text: string,
310
+ files: Pick<DownloadedTelegramFile, "path">[],
311
+ ): string {
312
+ if (files.length === 0) return text;
313
+ const dirs = [...new Set(files.map((file) => dirname(file.path)))];
314
+ const sameDir = dirs.length === 1;
315
+ const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
316
+ const items = sameDir
317
+ ? files.map((file) => `/${basename(file.path)}`)
318
+ : files.map((file) => file.path);
319
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
320
+ return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
321
+ }
322
+
284
323
  export function formatTelegramHistoryText(
285
324
  rawText: string,
286
325
  files: DownloadedTelegramFile[],
326
+ handlerOutputs: string[] = [],
287
327
  ): string {
288
328
  let summary = rawText.length > 0 ? rawText : "(no text)";
289
- if (files.length > 0) {
290
- summary += `\nAttachments:`;
291
- for (const file of files) {
292
- summary += `\n- ${file.path}`;
293
- }
294
- }
329
+ summary = appendTelegramAttachmentSection(summary, files);
330
+ summary = appendTelegramListSection(summary, "outputs", handlerOutputs);
295
331
  return summary;
296
332
  }
297
333
 
@@ -306,6 +342,7 @@ export async function downloadTelegramMessageFiles(
306
342
  fileName: file.fileName,
307
343
  isImage: file.isImage,
308
344
  mimeType: file.mimeType,
345
+ kind: file.kind,
309
346
  });
310
347
  }
311
348
  return downloaded;
@@ -325,6 +362,7 @@ export function collectTelegramFileInfos(
325
362
  file_id: photo.file_id,
326
363
  fileName: `photo-${message.message_id}.jpg`,
327
364
  mimeType: "image/jpeg",
365
+ kind: "photo",
328
366
  isImage: true,
329
367
  });
330
368
  }
@@ -340,6 +378,7 @@ export function collectTelegramFileInfos(
340
378
  file_id: message.document.file_id,
341
379
  fileName,
342
380
  mimeType: message.document.mime_type,
381
+ kind: "document",
343
382
  isImage: isImageMimeType(message.document.mime_type),
344
383
  });
345
384
  }
@@ -354,6 +393,7 @@ export function collectTelegramFileInfos(
354
393
  file_id: message.video.file_id,
355
394
  fileName,
356
395
  mimeType: message.video.mime_type,
396
+ kind: "video",
357
397
  isImage: false,
358
398
  });
359
399
  }
@@ -368,6 +408,7 @@ export function collectTelegramFileInfos(
368
408
  file_id: message.audio.file_id,
369
409
  fileName,
370
410
  mimeType: message.audio.mime_type,
411
+ kind: "audio",
371
412
  isImage: false,
372
413
  });
373
414
  }
@@ -379,6 +420,7 @@ export function collectTelegramFileInfos(
379
420
  ".ogg",
380
421
  )}`,
381
422
  mimeType: message.voice.mime_type,
423
+ kind: "voice",
382
424
  isImage: false,
383
425
  });
384
426
  }
@@ -393,6 +435,7 @@ export function collectTelegramFileInfos(
393
435
  file_id: message.animation.file_id,
394
436
  fileName,
395
437
  mimeType: message.animation.mime_type,
438
+ kind: "animation",
396
439
  isImage: false,
397
440
  });
398
441
  }
@@ -401,6 +444,7 @@ export function collectTelegramFileInfos(
401
444
  file_id: message.sticker.file_id,
402
445
  fileName: `sticker-${message.message_id}.webp`,
403
446
  mimeType: "image/webp",
447
+ kind: "sticker",
404
448
  isImage: true,
405
449
  });
406
450
  }
package/lib/pi.ts CHANGED
@@ -33,6 +33,7 @@ export interface PiSettingsManager {
33
33
 
34
34
  export interface PiExtensionApiRuntimePorts {
35
35
  sendUserMessage: ExtensionAPI["sendUserMessage"];
36
+ exec: ExtensionAPI["exec"];
36
37
  getThinkingLevel: ExtensionAPI["getThinkingLevel"];
37
38
  setThinkingLevel: ExtensionAPI["setThinkingLevel"];
38
39
  setModel: ExtensionAPI["setModel"];
@@ -41,11 +42,16 @@ export interface PiExtensionApiRuntimePorts {
41
42
  export function createExtensionApiRuntimePorts(
42
43
  api: Pick<
43
44
  ExtensionAPI,
44
- "sendUserMessage" | "getThinkingLevel" | "setThinkingLevel" | "setModel"
45
+ | "sendUserMessage"
46
+ | "exec"
47
+ | "getThinkingLevel"
48
+ | "setThinkingLevel"
49
+ | "setModel"
45
50
  >,
46
51
  ): PiExtensionApiRuntimePorts {
47
52
  return {
48
53
  sendUserMessage: (content) => api.sendUserMessage(content),
54
+ exec: (command, args, options) => api.exec(command, args, options),
49
55
  getThinkingLevel: () => api.getThinkingLevel(),
50
56
  setThinkingLevel: (level) => api.setThinkingLevel(level),
51
57
  setModel: (model) => api.setModel(model),
@@ -62,6 +68,10 @@ export function getExtensionContextModel(
62
68
  return ctx.model;
63
69
  }
64
70
 
71
+ export function getExtensionContextCwd(ctx: ExtensionContext): string {
72
+ return ctx.cwd;
73
+ }
74
+
65
75
  export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
66
76
  return ctx.isIdle();
67
77
  }
package/lib/queue.ts CHANGED
@@ -1100,6 +1100,7 @@ export interface TelegramPromptEnqueueControllerDeps<
1100
1100
  createTurn: (
1101
1101
  messages: TMessage[],
1102
1102
  historyTurns: PendingTelegramTurn[],
1103
+ ctx: TContext,
1103
1104
  ) => Promise<PendingTelegramTurn>;
1104
1105
  updateStatus: (ctx: TContext) => void;
1105
1106
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
@@ -1365,6 +1366,8 @@ export function createTelegramPromptEnqueueController<
1365
1366
  enqueue: (messages, ctx) =>
1366
1367
  enqueueTelegramPromptTurnRuntime(messages, {
1367
1368
  ...deps,
1369
+ createTurn: (nextMessages, historyTurns) =>
1370
+ deps.createTurn(nextMessages, historyTurns, ctx),
1368
1371
  updateStatus: () => deps.updateStatus(ctx),
1369
1372
  dispatchNextQueuedTelegramTurn: () =>
1370
1373
  deps.dispatchNextQueuedTelegramTurn(ctx),
@@ -28,7 +28,7 @@ const SYSTEM_PROMPT_SUFFIX = `
28
28
 
29
29
  Telegram bridge extension is active.
30
30
  - Messages forwarded from Telegram are prefixed with "[telegram]".
31
- - [telegram] messages may include local temp file paths for Telegram attachments. Read those files as needed.
31
+ - [telegram] messages may include [attachments] sections with a base directory plus relative local file entries. Resolve and read those files as needed.
32
32
  - Telegram is often read on narrow phone screens, so prefer narrow table columns when presenting tabular data; wide monospace tables can become unreadable.
33
33
  - If a [telegram] user asked for a file or generated artifact, use the telegram_attach tool with the local file path so the extension can send it with your next final reply.
34
34
  - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.`;
@@ -96,16 +96,49 @@ export function registerTelegramAttachmentTool(
96
96
 
97
97
  // --- Command Registration ---
98
98
 
99
+ export interface TelegramCommandStartPollingOptions {
100
+ force?: boolean;
101
+ }
102
+
103
+ export interface TelegramCommandStartPollingResult {
104
+ ok: boolean;
105
+ message?: string;
106
+ canTakeover?: boolean;
107
+ owner?: string;
108
+ }
109
+
99
110
  export interface TelegramCommandRegistrationDeps {
100
111
  promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
101
112
  getStatusLines: () => string[];
102
113
  reloadConfig: () => Promise<void>;
103
114
  hasBotToken: () => boolean;
104
- startPolling: (ctx: ExtensionCommandContext) => void | Promise<void>;
105
- stopPolling: () => Promise<void>;
115
+ startPolling: (
116
+ ctx: ExtensionCommandContext,
117
+ options?: TelegramCommandStartPollingOptions,
118
+ ) =>
119
+ | void
120
+ | Promise<void | TelegramCommandStartPollingResult>
121
+ | TelegramCommandStartPollingResult;
122
+ stopPolling: () => Promise<void | string>;
106
123
  updateStatus: (ctx: ExtensionCommandContext) => void;
107
124
  }
108
125
 
126
+ function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
127
+ return ctx.ui.theme.fg("accent", "pi-telegram");
128
+ }
129
+
130
+ function formatTelegramTakeoverPrompt(
131
+ ctx: ExtensionCommandContext,
132
+ owner?: string,
133
+ ): string {
134
+ const theme = ctx.ui.theme;
135
+ const action = theme.fg("warning", "move singleton lock here?");
136
+ const from = theme.fg("muted", "from:");
137
+ const to = theme.fg("muted", "to:");
138
+ const source = owner ?? "another pi instance";
139
+ return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
140
+ }
141
+
109
142
  export function registerTelegramCommands(
110
143
  pi: ExtensionAPI,
111
144
  deps: TelegramCommandRegistrationDeps,
@@ -130,14 +163,30 @@ export function registerTelegramCommands(
130
163
  await deps.promptForConfig(ctx);
131
164
  return;
132
165
  }
133
- await deps.startPolling(ctx);
166
+ let result = await deps.startPolling(ctx);
167
+ if (result && !result.ok && result.canTakeover) {
168
+ const confirmed = await ctx.ui.confirm(
169
+ formatTelegramTakeoverTitle(ctx),
170
+ formatTelegramTakeoverPrompt(ctx, result.owner),
171
+ );
172
+ if (!confirmed) {
173
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
174
+ deps.updateStatus(ctx);
175
+ return;
176
+ }
177
+ result = await deps.startPolling(ctx, { force: true });
178
+ }
179
+ if (result?.message) {
180
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
181
+ }
134
182
  deps.updateStatus(ctx);
135
183
  },
136
184
  });
137
185
  pi.registerCommand("telegram-disconnect", {
138
186
  description: "Stop the Telegram bridge in this pi session",
139
187
  handler: async (_args, ctx) => {
140
- await deps.stopPolling();
188
+ const message = await deps.stopPolling();
189
+ if (message) ctx.ui.notify(message, "info");
141
190
  deps.updateStatus(ctx);
142
191
  },
143
192
  });
@@ -225,6 +274,41 @@ export interface TelegramLifecycleRegistrationDeps {
225
274
  onAgentEnd: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void>;
226
275
  }
227
276
 
277
+ export interface TelegramSessionLifecycleHooks {
278
+ onSessionStart: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
279
+ onSessionShutdown: (
280
+ event: SessionShutdownEvent,
281
+ ctx: ExtensionContext,
282
+ ) => Promise<void>;
283
+ }
284
+
285
+ export interface TelegramExtraLifecycleHooks {
286
+ onSessionStart?: (
287
+ event: SessionStartEvent,
288
+ ctx: ExtensionContext,
289
+ ) => Promise<void>;
290
+ onSessionShutdown?: (
291
+ event: SessionShutdownEvent,
292
+ ctx: ExtensionContext,
293
+ ) => Promise<void>;
294
+ }
295
+
296
+ export function appendTelegramLifecycleHooks(
297
+ base: TelegramSessionLifecycleHooks,
298
+ extra: TelegramExtraLifecycleHooks,
299
+ ): TelegramSessionLifecycleHooks {
300
+ return {
301
+ onSessionStart: async (event, ctx) => {
302
+ await base.onSessionStart(event, ctx);
303
+ await extra.onSessionStart?.(event, ctx);
304
+ },
305
+ onSessionShutdown: async (event, ctx) => {
306
+ await base.onSessionShutdown(event, ctx);
307
+ await extra.onSessionShutdown?.(event, ctx);
308
+ },
309
+ };
310
+ }
311
+
228
312
  export function registerTelegramLifecycleHooks(
229
313
  pi: ExtensionAPI,
230
314
  deps: TelegramLifecycleRegistrationDeps,