@llblab/pi-telegram 0.17.5 → 0.18.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -19
  3. package/CHANGELOG.md +42 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +410 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +9 -2
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +409 -41
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/lifecycle.ts CHANGED
@@ -14,6 +14,9 @@ import type {
14
14
  SessionCompactEvent,
15
15
  SessionShutdownEvent,
16
16
  SessionStartEvent,
17
+ ToolExecutionEndEvent,
18
+ ToolExecutionStartEvent,
19
+ ToolExecutionUpdateEvent,
17
20
  } from "./pi.ts";
18
21
 
19
22
  let resetTransportReplyDedupFn: (() => void) | undefined;
@@ -25,7 +28,7 @@ export function setResetTransportReplyDedup(fn: () => void): void {
25
28
  export function createAgentStartDedupHook(
26
29
  inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>,
27
30
  ): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void> {
28
- return async function onAgentStartDedup(event, ctx) {
31
+ return async (event, ctx) => {
29
32
  if (resetTransportReplyDedupFn) resetTransportReplyDedupFn();
30
33
  return inner(event, ctx);
31
34
  };
@@ -73,11 +76,15 @@ export interface TelegramLifecycleRegistrationDeps {
73
76
  ctx: ExtensionContext,
74
77
  ) => Promise<void>;
75
78
  onToolExecutionStart: (
76
- event: unknown,
79
+ event: ToolExecutionStartEvent,
80
+ ctx: ExtensionContext,
81
+ ) => Promise<void> | void;
82
+ onToolExecutionUpdate?: (
83
+ event: ToolExecutionUpdateEvent,
77
84
  ctx: ExtensionContext,
78
85
  ) => Promise<void> | void;
79
86
  onToolExecutionEnd: (
80
- event: unknown,
87
+ event: ToolExecutionEndEvent,
81
88
  ctx: ExtensionContext,
82
89
  ) => Promise<void> | void;
83
90
  onMessageStart: (
@@ -148,6 +155,7 @@ export interface TelegramCompactionObserverRuntimeDeps<TContext> {
148
155
  updateStatus: (ctx: TContext) => void;
149
156
  startTypingLoop?: (ctx: TContext) => void;
150
157
  stopTypingLoop?: () => void;
158
+ shouldStartTypingLoop?: () => boolean;
151
159
  requestDeferredDispatchNextQueuedTelegramTurn: (
152
160
  dispatch: (ctx: TContext) => void,
153
161
  ) => void;
@@ -174,6 +182,7 @@ export function createTelegramCompactionObserverRuntime<TContext>(
174
182
  const setTimer = deps.setTimer ?? setTimeout;
175
183
  const clearTimer = deps.clearTimer ?? clearTimeout;
176
184
  let fallbackTimer: TelegramLifecycleTimer | undefined;
185
+ let typingStartedByObserver = false;
177
186
  const clearFallbackTimer = (): void => {
178
187
  if (!fallbackTimer) return;
179
188
  clearTimer(fallbackTimer);
@@ -187,13 +196,15 @@ export function createTelegramCompactionObserverRuntime<TContext>(
187
196
  return {
188
197
  onSessionBeforeCompact: (_event, ctx) => {
189
198
  deps.setCompactionInProgress(true);
190
- deps.startTypingLoop?.(ctx);
199
+ typingStartedByObserver = deps.shouldStartTypingLoop?.() ?? true;
200
+ if (typingStartedByObserver) deps.startTypingLoop?.(ctx);
191
201
  deps.updateStatus(ctx);
192
202
  clearFallbackTimer();
193
203
  fallbackTimer = setTimer(() => {
194
204
  fallbackTimer = undefined;
195
205
  deps.setCompactionInProgress(false);
196
- deps.stopTypingLoop?.();
206
+ if (typingStartedByObserver) deps.stopTypingLoop?.();
207
+ typingStartedByObserver = false;
197
208
  deps.updateStatus(ctx);
198
209
  deps.recordRuntimeEvent?.(
199
210
  "compact",
@@ -206,13 +217,15 @@ export function createTelegramCompactionObserverRuntime<TContext>(
206
217
  onSessionCompact: (_event, ctx) => {
207
218
  clearFallbackTimer();
208
219
  deps.setCompactionInProgress(false);
209
- deps.stopTypingLoop?.();
220
+ if (typingStartedByObserver) deps.stopTypingLoop?.();
221
+ typingStartedByObserver = false;
210
222
  deps.updateStatus(ctx);
211
223
  requestDispatch();
212
224
  },
213
225
  onSessionShutdown: () => {
214
226
  clearFallbackTimer();
215
- deps.stopTypingLoop?.();
227
+ if (typingStartedByObserver) deps.stopTypingLoop?.();
228
+ typingStartedByObserver = false;
216
229
  },
217
230
  };
218
231
  }
@@ -329,6 +342,9 @@ export function registerTelegramLifecycleHooks(
329
342
  pi.on("tool_execution_start", async (event, ctx) => {
330
343
  await deps.onToolExecutionStart(event, ctx);
331
344
  });
345
+ pi.on("tool_execution_update", async (event, ctx) => {
346
+ await deps.onToolExecutionUpdate?.(event, ctx);
347
+ });
332
348
  pi.on("tool_execution_end", async (event, ctx) => {
333
349
  await deps.onToolExecutionEnd(event, ctx);
334
350
  });
package/lib/locks.ts CHANGED
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
16
16
  import { dirname, join, resolve } from "node:path";
17
17
 
18
18
  export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
19
+ export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 5_000;
19
20
 
20
21
  function getAgentDir(): string {
21
22
  return process.env.PI_CODING_AGENT_DIR
@@ -30,6 +31,11 @@ function getLocksPath(): string {
30
31
  export interface TelegramLockEntry {
31
32
  pid: number;
32
33
  cwd?: string;
34
+ instanceId?: string;
35
+ heartbeatMs?: number;
36
+ leaderEpoch?: number;
37
+ busSocketPath?: string;
38
+ busSecret?: string;
33
39
  }
34
40
 
35
41
  export interface TelegramLockContext {
@@ -59,13 +65,18 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
59
65
  getState: () => TelegramLockState;
60
66
  getStatusLabel: () => string;
61
67
  owns: (ctx?: TelegramLockContext) => boolean;
68
+ refresh: (ctx?: TelegramLockContext) => boolean;
62
69
  }
63
70
 
64
- export interface TelegramLockOwnershipGuard<TContext extends TelegramLockContext> {
71
+ export interface TelegramLockOwnershipGuard<
72
+ TContext extends TelegramLockContext,
73
+ > {
65
74
  ownsContext: (ctx: TContext) => boolean;
66
75
  }
67
76
 
68
- export interface TelegramLockContextStore<TContext extends TelegramLockContext> {
77
+ export interface TelegramLockContextStore<
78
+ TContext extends TelegramLockContext,
79
+ > {
69
80
  get: () => TContext | undefined;
70
81
  }
71
82
 
@@ -74,44 +85,11 @@ export interface TelegramLockRuntimeOptions {
74
85
  locksPath?: string;
75
86
  pid?: number;
76
87
  isProcessAlive?: (pid: number) => boolean;
77
- }
78
-
79
- export interface TelegramLockedPollingStartOptions {
80
- force?: boolean;
81
- }
82
-
83
- export type TelegramLockedPollingStartResult =
84
- | { ok: true; message: string; canTakeover?: false }
85
- | { ok: false; message: string; canTakeover?: boolean; owner?: string };
86
-
87
- export interface TelegramLockedPollingRuntime<
88
- TContext extends TelegramLockContext,
89
- > {
90
- start: (
91
- ctx: TContext,
92
- options?: TelegramLockedPollingStartOptions,
93
- ) => Promise<TelegramLockedPollingStartResult>;
94
- stop: () => Promise<string>;
95
- suspend: () => Promise<void>;
96
- onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
97
- }
98
-
99
- export interface TelegramLockedPollingRuntimeDeps<
100
- TContext extends TelegramLockContext,
101
- > {
102
- lock: TelegramLockRuntime<TContext>;
103
- hasBotToken: () => boolean;
104
- canStartPolling?: (ctx: TContext) => boolean;
105
- formatStartBlockedMessage?: (ctx: TContext) => string;
106
- startPolling: (ctx: TContext) => void | Promise<void>;
107
- stopPolling: () => Promise<void>;
108
- updateStatus: (ctx: TContext) => void;
109
- recordRuntimeEvent?: (
110
- category: string,
111
- error: unknown,
112
- details?: Record<string, unknown>,
113
- ) => void;
114
- ownershipCheckMs?: number;
88
+ instanceId?: string;
89
+ busSocketPath?: string;
90
+ busSecret?: string;
91
+ getNowMs?: () => number;
92
+ staleHeartbeatMs?: number;
115
93
  }
116
94
 
117
95
  export function readLocks(path = getLocksPath()): Record<string, unknown> {
@@ -155,6 +133,18 @@ export function parseTelegramLockEntry(
155
133
  return {
156
134
  pid: record.pid,
157
135
  cwd: typeof record.cwd === "string" ? record.cwd : undefined,
136
+ instanceId:
137
+ typeof record.instanceId === "string" ? record.instanceId : undefined,
138
+ heartbeatMs:
139
+ typeof record.heartbeatMs === "number" ? record.heartbeatMs : undefined,
140
+ leaderEpoch:
141
+ typeof record.leaderEpoch === "number" ? record.leaderEpoch : undefined,
142
+ busSocketPath:
143
+ typeof record.busSocketPath === "string"
144
+ ? record.busSocketPath
145
+ : undefined,
146
+ busSecret:
147
+ typeof record.busSecret === "string" ? record.busSecret : undefined,
158
148
  };
159
149
  }
160
150
 
@@ -168,7 +158,7 @@ export function isProcessAlive(pid: number): boolean {
168
158
  }
169
159
  }
170
160
 
171
- function formatLock(lock: TelegramLockEntry): string {
161
+ export function formatTelegramLockEntry(lock: TelegramLockEntry): string {
172
162
  return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
173
163
  }
174
164
 
@@ -176,9 +166,18 @@ function getLockState(
176
166
  lock: TelegramLockEntry | undefined,
177
167
  pid: number,
178
168
  isAlive: (pid: number) => boolean,
169
+ options: { nowMs?: number; staleHeartbeatMs?: number } = {},
179
170
  ): TelegramLockState {
180
171
  if (!lock) return { kind: "inactive" };
181
172
  if (lock.pid === pid) return { kind: "active-here", lock };
173
+ if (
174
+ typeof lock.heartbeatMs === "number" &&
175
+ typeof options.nowMs === "number" &&
176
+ typeof options.staleHeartbeatMs === "number" &&
177
+ options.nowMs - lock.heartbeatMs > options.staleHeartbeatMs
178
+ ) {
179
+ return { kind: "stale", lock };
180
+ }
182
181
  if (isAlive(lock.pid)) return { kind: "active-elsewhere", lock };
183
182
  return { kind: "stale", lock };
184
183
  }
@@ -192,8 +191,26 @@ function ownsLockContext(
192
191
  return !lock.cwd || !ctx || lock.cwd === ctx.cwd;
193
192
  }
194
193
 
195
- function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
196
- return { cwd: ctx.cwd };
194
+ function createLockEntry(
195
+ pid: number,
196
+ ctx: TelegramLockContext,
197
+ options: {
198
+ instanceId?: string;
199
+ busSocketPath?: string;
200
+ busSecret?: string;
201
+ getNowMs?: () => number;
202
+ },
203
+ ): TelegramLockEntry {
204
+ const lock: TelegramLockEntry = { pid, cwd: ctx.cwd };
205
+ if (options.instanceId) {
206
+ const nowMs = options.getNowMs?.();
207
+ lock.instanceId = options.instanceId;
208
+ lock.heartbeatMs = nowMs;
209
+ lock.leaderEpoch = nowMs;
210
+ }
211
+ if (options.busSocketPath) lock.busSocketPath = options.busSocketPath;
212
+ if (options.busSecret) lock.busSecret = options.busSecret;
213
+ return lock;
197
214
  }
198
215
 
199
216
  function formatLockState(state: TelegramLockState): string {
@@ -203,9 +220,9 @@ function formatLockState(state: TelegramLockState): string {
203
220
  case "active-here":
204
221
  return "active here";
205
222
  case "active-elsewhere":
206
- return `active elsewhere (${formatLock(state.lock)})`;
223
+ return `active elsewhere (${formatTelegramLockEntry(state.lock)})`;
207
224
  case "stale":
208
- return `stale (${formatLock(state.lock)})`;
225
+ return `stale (${formatTelegramLockEntry(state.lock)})`;
209
226
  }
210
227
  }
211
228
 
@@ -216,6 +233,11 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
216
233
  const locksPath = options.locksPath ?? getLocksPath();
217
234
  const pid = options.pid ?? process.pid;
218
235
  const isAlive = options.isProcessAlive ?? isProcessAlive;
236
+ const getNowMs = options.getNowMs ?? Date.now;
237
+ const stateOptions = () => ({
238
+ nowMs: getNowMs(),
239
+ staleHeartbeatMs: options.staleHeartbeatMs,
240
+ });
219
241
  const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
220
242
  const writeLock = (lock: TelegramLockEntry) => {
221
243
  const locks = readLocks(locksPath);
@@ -224,15 +246,20 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
224
246
  };
225
247
  return {
226
248
  acquire: (ctx, acquireOptions = {}) => {
227
- const state = getLockState(readLock(), pid, isAlive);
249
+ const state = getLockState(readLock(), pid, isAlive, stateOptions());
228
250
  if (state.kind === "active-elsewhere" && !acquireOptions.force)
229
251
  return { ok: false, lock: state.lock };
230
- const lock = { pid, cwd: ctx.cwd };
252
+ const lock = createLockEntry(pid, ctx, {
253
+ instanceId: options.instanceId,
254
+ busSocketPath: options.busSocketPath,
255
+ busSecret: options.busSecret,
256
+ getNowMs,
257
+ });
231
258
  writeLock(lock);
232
259
  return { ok: true, lock, replacedStale: state.kind === "stale" };
233
260
  },
234
261
  release: () => {
235
- const state = getLockState(readLock(), pid, isAlive);
262
+ const state = getLockState(readLock(), pid, isAlive, stateOptions());
236
263
  if (state.kind === "active-here" || state.kind === "stale") {
237
264
  const locks = readLocks(locksPath);
238
265
  delete locks[key];
@@ -240,18 +267,33 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
240
267
  }
241
268
  return state;
242
269
  },
243
- getState: () => getLockState(readLock(), pid, isAlive),
270
+ getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
244
271
  getStatusLabel: () =>
245
- formatLockState(getLockState(readLock(), pid, isAlive)),
272
+ formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
246
273
  owns: (ctx) => ownsLockContext(readLock(), pid, ctx),
274
+ refresh: (ctx) => {
275
+ const lock = readLock();
276
+ if (!lock || !ownsLockContext(lock, pid, ctx)) return false;
277
+ if (!options.instanceId) return true;
278
+ writeLock({
279
+ pid: lock.pid,
280
+ ...(lock.cwd ? { cwd: lock.cwd } : {}),
281
+ instanceId: options.instanceId,
282
+ heartbeatMs: getNowMs(),
283
+ leaderEpoch: lock.leaderEpoch,
284
+ ...(options.busSocketPath
285
+ ? { busSocketPath: options.busSocketPath }
286
+ : {}),
287
+ busSecret: options.busSecret ?? lock.busSecret,
288
+ });
289
+ return true;
290
+ },
247
291
  };
248
292
  }
249
293
 
250
294
  export function createTelegramLockOwnershipGuard<
251
295
  TContext extends TelegramLockContext,
252
- >(
253
- lock: TelegramLockRuntime<TContext>,
254
- ): TelegramLockOwnershipGuard<TContext> {
296
+ >(lock: TelegramLockRuntime<TContext>): TelegramLockOwnershipGuard<TContext> {
255
297
  return {
256
298
  ownsContext: (ctx) => lock.owns(ctx),
257
299
  };
@@ -269,6 +311,62 @@ export function createTelegramDirectDeliveryOwnershipChecker<
269
311
  };
270
312
  }
271
313
 
314
+ export interface TelegramLockedPollingStartOptions {
315
+ force?: boolean;
316
+ forceFreshLeaderThread?: boolean;
317
+ }
318
+
319
+ export type TelegramLockedPollingStartResult =
320
+ | { ok: true; message?: string; canTakeover?: false }
321
+ | { ok: false; message: string; canTakeover?: boolean; owner?: string };
322
+
323
+ export interface TelegramLockedPollingRuntime<
324
+ TContext extends TelegramLockContext,
325
+ > {
326
+ start: (
327
+ ctx: TContext,
328
+ options?: TelegramLockedPollingStartOptions,
329
+ ) => Promise<TelegramLockedPollingStartResult>;
330
+ stop: () => Promise<string>;
331
+ suspend: () => Promise<void>;
332
+ onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
333
+ registerFollowerWithOwner?: (
334
+ ctx: TContext,
335
+ owner: TelegramLockEntry,
336
+ ) => boolean | undefined | Promise<boolean | undefined>;
337
+ stopFollowerRegistration?: () => void;
338
+ }
339
+
340
+ export interface TelegramLockedPollingRuntimeDeps<
341
+ TContext extends TelegramLockContext,
342
+ > {
343
+ lock: TelegramLockRuntime<TContext>;
344
+ hasBotToken: () => boolean;
345
+ canStartPolling?: (ctx: TContext) => boolean;
346
+ formatStartBlockedMessage?: (ctx: TContext) => string;
347
+ startPolling: (
348
+ ctx: TContext,
349
+ options?: TelegramLockedPollingStartOptions,
350
+ ) => void | Promise<void>;
351
+ stopPolling: () => Promise<void>;
352
+ registerFollowerWithOwner?: (
353
+ ctx: TContext,
354
+ owner: TelegramLockEntry,
355
+ ) => boolean | undefined | Promise<boolean | undefined>;
356
+ stopFollowerRegistration?: () => void;
357
+ updateStatus: (ctx: TContext) => void;
358
+ recordRuntimeEvent?: (
359
+ category: string,
360
+ error: unknown,
361
+ details?: Record<string, unknown>,
362
+ ) => void;
363
+ ownershipCheckMs?: number;
364
+ }
365
+
366
+ function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
367
+ return { cwd: ctx.cwd };
368
+ }
369
+
272
370
  export function createTelegramLockedPollingRuntime<
273
371
  TContext extends TelegramLockContext,
274
372
  >(
@@ -276,6 +374,8 @@ export function createTelegramLockedPollingRuntime<
276
374
  ): TelegramLockedPollingRuntime<TContext> {
277
375
  let ownershipInterval: ReturnType<typeof setInterval> | undefined;
278
376
  let ownershipStop: Promise<void> | undefined;
377
+ let sessionAutoStartRun: Promise<void> | undefined;
378
+ let sessionAutoStartGeneration = 0;
279
379
  const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
280
380
  const stopOwnershipWatcher = () => {
281
381
  if (!ownershipInterval) return;
@@ -283,7 +383,12 @@ export function createTelegramLockedPollingRuntime<
283
383
  ownershipInterval = undefined;
284
384
  };
285
385
  const suspendPolling = async () => {
386
+ sessionAutoStartGeneration += 1;
387
+ deps.stopFollowerRegistration?.();
286
388
  stopOwnershipWatcher();
389
+ if (sessionAutoStartRun) {
390
+ await sessionAutoStartRun;
391
+ }
287
392
  if (ownershipStop) {
288
393
  await ownershipStop;
289
394
  return;
@@ -306,7 +411,7 @@ export function createTelegramLockedPollingRuntime<
306
411
  const owner = snapshotLockContext(ctx);
307
412
  stopOwnershipWatcher();
308
413
  ownershipInterval = setInterval(() => {
309
- if (deps.lock.owns(owner)) return;
414
+ if (deps.lock.refresh(owner)) return;
310
415
  stopAfterOwnershipLoss();
311
416
  }, ownershipCheckMs);
312
417
  ownershipInterval.unref?.();
@@ -315,24 +420,55 @@ export function createTelegramLockedPollingRuntime<
315
420
  deps.canStartPolling?.(ctx) ?? true;
316
421
  const formatStartBlockedMessage = (ctx: TContext): string =>
317
422
  deps.formatStartBlockedMessage?.(ctx) ??
318
- "Telegram polling is unavailable in this π run mode.";
423
+ "Telegram polling is unavailable in this Pi run mode.";
319
424
  return {
320
425
  start: async (ctx, options = {}) => {
321
- if (!deps.hasBotToken())
426
+ if (!deps.hasBotToken()) {
322
427
  return { ok: false, message: "Telegram bot is not configured." };
428
+ }
323
429
  if (!canStartPolling(ctx)) {
324
430
  return { ok: false, message: formatStartBlockedMessage(ctx) };
325
431
  }
326
432
  const acquired = deps.lock.acquire(ctx, options);
327
433
  if (!acquired.ok) {
434
+ if (deps.registerFollowerWithOwner) {
435
+ let failureMessage: string | undefined;
436
+ try {
437
+ const registered = await deps.registerFollowerWithOwner(
438
+ ctx,
439
+ acquired.lock,
440
+ );
441
+ if (registered) {
442
+ deps.updateStatus(ctx);
443
+ return { ok: true, canTakeover: false };
444
+ }
445
+ if (registered === false) failureMessage = "not registered";
446
+ } catch (error) {
447
+ failureMessage =
448
+ error instanceof Error ? error.message : String(error);
449
+ deps.recordRuntimeEvent?.("bus", error, {
450
+ phase: "follower-register",
451
+ });
452
+ }
453
+ if (failureMessage) {
454
+ const owner = formatTelegramLockEntry(acquired.lock);
455
+ return {
456
+ ok: false,
457
+ canTakeover: false,
458
+ owner,
459
+ message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${failureMessage}.`,
460
+ };
461
+ }
462
+ }
463
+ const owner = formatTelegramLockEntry(acquired.lock);
328
464
  return {
329
465
  ok: false,
330
466
  canTakeover: true,
331
- owner: formatLock(acquired.lock),
332
- message: `Telegram bridge is active in another π instance (${formatLock(acquired.lock)}).`,
467
+ owner,
468
+ message: `Telegram bridge is active in another Pi instance (${owner}).`,
333
469
  };
334
470
  }
335
- await deps.startPolling(ctx);
471
+ await deps.startPolling(ctx, options);
336
472
  startOwnershipWatcher(ctx);
337
473
  deps.updateStatus(ctx);
338
474
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
@@ -342,10 +478,11 @@ export function createTelegramLockedPollingRuntime<
342
478
  await suspendPolling();
343
479
  const state = deps.lock.release();
344
480
  if (state.kind === "active-elsewhere") {
345
- return `Telegram bridge is active in another π instance (${formatLock(state.lock)}).`;
481
+ return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
482
+ }
483
+ if (state.kind === "stale") {
484
+ return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
346
485
  }
347
- if (state.kind === "stale")
348
- return `Removed stale Telegram bridge lock (${formatLock(state.lock)}).`;
349
486
  return "Telegram bridge disconnected.";
350
487
  },
351
488
  suspend: suspendPolling,
@@ -357,17 +494,44 @@ export function createTelegramLockedPollingRuntime<
357
494
  const canResumeStaleSameCwd =
358
495
  state?.kind === "stale" && state.lock.cwd === ctx.cwd;
359
496
  if (!ownsCurrentLock && !canResumeStaleSameCwd) return;
360
- try {
497
+ sessionAutoStartGeneration += 1;
498
+ const generation = sessionAutoStartGeneration;
499
+ const startedAtMs = Date.now();
500
+ deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
501
+ phase: "auto-start-scheduled",
502
+ });
503
+ const run = (async () => {
504
+ await new Promise((resolve) => setTimeout(resolve, 0));
505
+ if (generation !== sessionAutoStartGeneration) return;
361
506
  if (canResumeStaleSameCwd) {
362
507
  const acquired = deps.lock.acquire(ctx);
363
508
  if (!acquired.ok) return;
364
509
  }
510
+ if (generation !== sessionAutoStartGeneration) return;
365
511
  await deps.startPolling(ctx);
512
+ if (generation !== sessionAutoStartGeneration) return;
366
513
  startOwnershipWatcher(ctx);
367
514
  deps.updateStatus(ctx);
368
- } catch (error) {
369
- deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
370
- }
515
+ deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
516
+ phase: "auto-start-complete",
517
+ durationMs: Date.now() - startedAtMs,
518
+ });
519
+ })()
520
+ .catch((error) => {
521
+ deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
522
+ })
523
+ .finally(() => {
524
+ if (sessionAutoStartRun === run) sessionAutoStartRun = undefined;
525
+ });
526
+ sessionAutoStartRun = run;
371
527
  },
528
+ registerFollowerWithOwner: deps.registerFollowerWithOwner
529
+ ? async (ctx, owner) => {
530
+ const registered = await deps.registerFollowerWithOwner?.(ctx, owner);
531
+ if (registered) deps.updateStatus(ctx);
532
+ return registered === true;
533
+ }
534
+ : undefined,
535
+ stopFollowerRegistration: deps.stopFollowerRegistration,
372
536
  };
373
537
  }
package/lib/media.ts CHANGED
@@ -63,6 +63,7 @@ export interface TelegramMediaMessage {
63
63
  export interface TelegramMediaGroupMessage {
64
64
  message_id: number;
65
65
  chat: { id: number };
66
+ message_thread_id?: number;
66
67
  media_group_id?: string;
67
68
  }
68
69
 
@@ -232,7 +233,9 @@ function extractTelegramRichBlockText(block: unknown): string {
232
233
  .map((row) =>
233
234
  Array.isArray(row)
234
235
  ? row
235
- .map((cell) => extractTelegramRichText(getObjectField(cell, "text")))
236
+ .map((cell) =>
237
+ extractTelegramRichText(getObjectField(cell, "text")),
238
+ )
236
239
  .filter(Boolean)
237
240
  .join(" | ")
238
241
  : "",
@@ -328,6 +331,27 @@ export function extractFirstTelegramMessageText(
328
331
  return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
329
332
  }
330
333
 
334
+ export function hasTelegramMessagePromptContent(
335
+ message: TelegramMediaMessage,
336
+ ): boolean {
337
+ return (
338
+ !!extractTelegramMessageText(message) ||
339
+ (Array.isArray(message.photo) && message.photo.length > 0) ||
340
+ !!message.document ||
341
+ !!message.video ||
342
+ !!message.audio ||
343
+ !!message.voice ||
344
+ !!message.animation ||
345
+ !!message.sticker
346
+ );
347
+ }
348
+
349
+ export function hasTelegramMessagesPromptContent(
350
+ messages: TelegramMediaMessage[],
351
+ ): boolean {
352
+ return messages.some(hasTelegramMessagePromptContent);
353
+ }
354
+
331
355
  export function collectTelegramMessageIds(
332
356
  messages: TelegramMediaMessage[],
333
357
  ): number[] {
@@ -338,7 +362,11 @@ export function getTelegramMediaGroupKey(
338
362
  message: TelegramMediaGroupMessage,
339
363
  ): string | undefined {
340
364
  if (!message.media_group_id) return undefined;
341
- return `${message.chat.id}:${message.media_group_id}`;
365
+ const threadKey =
366
+ typeof message.message_thread_id === "number"
367
+ ? `thread:${message.message_thread_id}`
368
+ : "private";
369
+ return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
342
370
  }
343
371
 
344
372
  export function removePendingTelegramMediaGroupMessages<