@llblab/pi-telegram 0.51.4 → 0.51.6
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/AGENTS.md +1 -1
- package/BACKLOG.md +1 -0
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/dist/lib/bindings.d.ts +4 -1
- package/dist/lib/bindings.js +16 -12
- package/dist/lib/bus-follower.d.ts +3 -1
- package/dist/lib/bus-follower.js +62 -3
- package/dist/lib/bus-leader.d.ts +21 -0
- package/dist/lib/bus-leader.js +144 -1
- package/dist/lib/bus.d.ts +9 -0
- package/dist/lib/bus.js +19 -0
- package/dist/lib/commands.d.ts +25 -3
- package/dist/lib/commands.js +166 -89
- package/dist/lib/extension.js +42 -0
- package/dist/lib/lifecycle.d.ts +57 -2
- package/dist/lib/lifecycle.js +109 -4
- package/dist/lib/locks.js +5 -1
- package/dist/lib/model.js +2 -4
- package/dist/lib/polling.js +6 -2
- package/dist/lib/status.d.ts +3 -1
- package/dist/lib/status.js +31 -1
- package/dist/lib/sync.js +4 -4
- package/dist/lib/threads.d.ts +8 -0
- package/dist/lib/threads.js +30 -9
- package/dist/lib/workspace-retirement.d.ts +2 -2
- package/dist/lib/workspace-retirement.js +48 -12
- package/dist/package.json +1 -1
- package/docs/architecture.md +6 -5
- package/docs/multi-instance-bus.md +4 -2
- package/docs/public-api.md +3 -3
- package/docs/ui-style.md +4 -0
- package/lib/bindings.ts +17 -10
- package/lib/bus-follower.ts +87 -3
- package/lib/bus-leader.ts +197 -0
- package/lib/bus.ts +33 -0
- package/lib/commands.ts +173 -103
- package/lib/extension.ts +40 -0
- package/lib/lifecycle.ts +140 -4
- package/lib/locks.ts +6 -1
- package/lib/model.ts +2 -4
- package/lib/polling.ts +10 -2
- package/lib/status.ts +30 -1
- package/lib/sync.ts +4 -4
- package/lib/threads.ts +40 -8
- package/lib/workspace-retirement.ts +46 -13
- package/package.json +1 -1
package/lib/commands.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import type * as Pi from "./pi.ts";
|
|
15
15
|
import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
|
|
16
16
|
import { escapeHtml } from "./rendering.ts";
|
|
17
|
-
import type
|
|
17
|
+
import { formatTelegramConnectionFailure, type TelegramBridgeStatusLineOptions } from "./status.ts";
|
|
18
18
|
import type { TelegramSessionReplacementIntent } from "./threads.ts";
|
|
19
19
|
import {
|
|
20
20
|
createTelegramControlItemBuilder,
|
|
@@ -399,14 +399,24 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
399
399
|
recoverPollingStart?: (
|
|
400
400
|
error: unknown,
|
|
401
401
|
) => Promise<TelegramPollingStartRecoveryResult>;
|
|
402
|
+
recordConnectionEvent?: (error: unknown, phase: string) => void;
|
|
402
403
|
getDisconnectThreadName?: () => string | undefined;
|
|
403
404
|
queueAgentConnectionContext?: (connected: boolean) => void;
|
|
404
405
|
updateStatus: (ctx: ExtensionCommandContext) => void;
|
|
406
|
+
isContextCurrent?: (ctx: ExtensionCommandContext) => boolean;
|
|
407
|
+
getSessionGeneration?: () => number;
|
|
408
|
+
connectionIntent?: {
|
|
409
|
+
begin(cwd: string, profileName?: string): string;
|
|
410
|
+
finish(id: string): void;
|
|
411
|
+
isActive(id: string): boolean;
|
|
412
|
+
cancel(): void;
|
|
413
|
+
};
|
|
405
414
|
getProfileNames?: () => string[];
|
|
406
|
-
activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
415
|
+
activateDefaultProfileConfig?: (ctx: ExtensionCommandContext, isCurrent: () => boolean) => Promise<void>;
|
|
407
416
|
activateProfileConfig?: (
|
|
408
417
|
ctx: ExtensionCommandContext,
|
|
409
418
|
profileName: string,
|
|
419
|
+
isCurrent: () => boolean,
|
|
410
420
|
) => Promise<boolean>;
|
|
411
421
|
}
|
|
412
422
|
|
|
@@ -504,6 +514,14 @@ export function registerTelegramBridgeCommands(
|
|
|
504
514
|
pi.registerCommand("telegram-connect", {
|
|
505
515
|
description: "<profile> — Start Telegram bridge",
|
|
506
516
|
handler: async (args, ctx) => {
|
|
517
|
+
const sessionGeneration = deps.getSessionGeneration?.();
|
|
518
|
+
let intentId: string | undefined;
|
|
519
|
+
// Pi context getters throw after replacement; check plain intent/generation first.
|
|
520
|
+
const isCurrent = () =>
|
|
521
|
+
(!intentId || deps.connectionIntent?.isActive(intentId) !== false) &&
|
|
522
|
+
(sessionGeneration === undefined || deps.getSessionGeneration?.() === sessionGeneration) &&
|
|
523
|
+
deps.isContextCurrent?.(ctx) !== false;
|
|
524
|
+
if (!isCurrent()) return;
|
|
507
525
|
if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
|
|
508
526
|
ctx.ui.notify(
|
|
509
527
|
"Thread names are configured from Telegram, not from Pi commands.",
|
|
@@ -513,121 +531,141 @@ export function registerTelegramBridgeCommands(
|
|
|
513
531
|
return;
|
|
514
532
|
}
|
|
515
533
|
const profileName = parseTelegramProfileArg(args);
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if (
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
534
|
+
intentId = deps.connectionIntent?.begin(ctx.cwd, profileName);
|
|
535
|
+
try {
|
|
536
|
+
if (profileName && deps.activateProfileConfig) {
|
|
537
|
+
const ok = await deps.activateProfileConfig(ctx, profileName, isCurrent);
|
|
538
|
+
if (!isCurrent()) return;
|
|
539
|
+
if (!ok) {
|
|
540
|
+
ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
|
|
541
|
+
deps.updateStatus(ctx);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
ctx.ui.notify(`Activated profile "${profileName}".`, "info");
|
|
545
|
+
} else {
|
|
546
|
+
await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ?? deps.reloadConfig());
|
|
547
|
+
if (!isCurrent()) return;
|
|
522
548
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
);
|
|
536
|
-
deps.updateStatus(ctx);
|
|
549
|
+
if (!deps.hasBotToken()) {
|
|
550
|
+
const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
|
|
551
|
+
if (botTokenDiagnostic) ctx.ui.notify(botTokenDiagnostic, "error");
|
|
552
|
+
const profileNames = deps.getProfileNames?.() ?? [];
|
|
553
|
+
if (!profileName && profileNames.length > 0) {
|
|
554
|
+
ctx.ui.notify(
|
|
555
|
+
`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`,
|
|
556
|
+
"info",
|
|
557
|
+
);
|
|
558
|
+
deps.updateStatus(ctx);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
await deps.promptForConfig(ctx, profileName);
|
|
537
562
|
return;
|
|
538
563
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const startWithRecovery = async (
|
|
544
|
-
options: TelegramBridgeCommandStartPollingOptions,
|
|
545
|
-
): Promise<void | TelegramBridgeCommandStartPollingResult> => {
|
|
546
|
-
try {
|
|
547
|
-
return await deps.startPolling(ctx, options);
|
|
548
|
-
} catch (error) {
|
|
549
|
-
if (!deps.recoverPollingStart || recoveryUsed) throw error;
|
|
550
|
-
const recovery = await deps.recoverPollingStart(error);
|
|
551
|
-
if (recovery.kind === "unhandled") throw error;
|
|
552
|
-
if (recovery.kind === "blocked") {
|
|
553
|
-
return { ok: false, message: recovery.message };
|
|
554
|
-
}
|
|
555
|
-
recoveryUsed = true;
|
|
564
|
+
let recoveryUsed = false;
|
|
565
|
+
const startWithRecovery = async (
|
|
566
|
+
options: TelegramBridgeCommandStartPollingOptions,
|
|
567
|
+
): Promise<void | (TelegramBridgeCommandStartPollingResult & { notice?: string })> => {
|
|
556
568
|
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
569
|
+
return await deps.startPolling(ctx, options);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (!isCurrent()) return;
|
|
572
|
+
if (!deps.recoverPollingStart || recoveryUsed) throw error;
|
|
573
|
+
deps.recordConnectionEvent?.(error, "polling-start");
|
|
574
|
+
const recovery = await deps.recoverPollingStart(error);
|
|
575
|
+
if (!isCurrent()) return;
|
|
576
|
+
if (recovery.kind === "unhandled") throw error;
|
|
577
|
+
if (recovery.kind === "blocked") {
|
|
578
|
+
return { ok: false, message: recovery.message,
|
|
579
|
+
notice: "Telegram recovery blocked. Check /telegram-status --debug." };
|
|
580
|
+
}
|
|
581
|
+
recoveryUsed = true;
|
|
582
|
+
deps.recordConnectionEvent?.(recovery.message, "recovery");
|
|
583
|
+
try {
|
|
584
|
+
const retry = await deps.startPolling(ctx, options);
|
|
585
|
+
if (!isCurrent()) return;
|
|
586
|
+
if (!retry) return { ok: true, message: "Telegram bridge connected; temporary state recovered." };
|
|
587
|
+
return {
|
|
588
|
+
...retry,
|
|
589
|
+
message: retry.ok
|
|
590
|
+
? "Telegram bridge connected; temporary state recovered."
|
|
591
|
+
: retry.message,
|
|
592
|
+
};
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (!isCurrent()) return;
|
|
595
|
+
deps.recordConnectionEvent?.(error, "recovery-retry");
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
notice: "Telegram recovery failed. Restart this Pi instance.",
|
|
599
|
+
};
|
|
560
600
|
}
|
|
561
|
-
return {
|
|
562
|
-
...retry,
|
|
563
|
-
message: retry.ok
|
|
564
|
-
? `${recovery.message} ${retry.message ?? "Telegram bridge connected."}`
|
|
565
|
-
: retry.message,
|
|
566
|
-
};
|
|
567
|
-
} catch {
|
|
568
|
-
return {
|
|
569
|
-
ok: false,
|
|
570
|
-
message:
|
|
571
|
-
"Telegram temporary state was recovered, but the bridge could not restart. Restart this Pi instance and run /telegram-connect again.",
|
|
572
|
-
};
|
|
573
601
|
}
|
|
602
|
+
};
|
|
603
|
+
let result = await startWithRecovery({ forceFreshLeaderThread: true });
|
|
604
|
+
if (!isCurrent()) return;
|
|
605
|
+
if (result && !result.ok && result.canTakeover) {
|
|
606
|
+
const confirmed = await ctx.ui.confirm(
|
|
607
|
+
formatTelegramTakeoverTitle(ctx),
|
|
608
|
+
formatTelegramTakeoverPrompt(ctx, result.owner),
|
|
609
|
+
);
|
|
610
|
+
if (!isCurrent()) return;
|
|
611
|
+
if (!confirmed) {
|
|
612
|
+
ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
|
|
613
|
+
deps.updateStatus(ctx);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
result = await startWithRecovery({ force: true, forceFreshLeaderThread: true });
|
|
617
|
+
if (!isCurrent()) return;
|
|
574
618
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const confirmed = await ctx.ui.confirm(
|
|
581
|
-
formatTelegramTakeoverTitle(ctx),
|
|
582
|
-
formatTelegramTakeoverPrompt(ctx, result.owner),
|
|
583
|
-
);
|
|
584
|
-
if (!confirmed) {
|
|
585
|
-
ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
|
|
586
|
-
deps.updateStatus(ctx);
|
|
587
|
-
return;
|
|
619
|
+
if (result && !result.ok) {
|
|
620
|
+
if (result.message) deps.recordConnectionEvent?.(result.message, "connect-refused");
|
|
621
|
+
ctx.ui.notify(result.notice ?? formatTelegramConnectionFailure(result.message), "warning");
|
|
622
|
+
} else if (result?.message) {
|
|
623
|
+
ctx.ui.notify(result.message, "info");
|
|
588
624
|
}
|
|
589
|
-
result
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
deps.queueAgentConnectionContext?.(true);
|
|
625
|
+
if (!result || result.ok) deps.queueAgentConnectionContext?.(true);
|
|
626
|
+
deps.updateStatus(ctx);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (!isCurrent()) return;
|
|
629
|
+
deps.recordConnectionEvent?.(error, "connect");
|
|
630
|
+
ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
|
|
631
|
+
deps.updateStatus(ctx);
|
|
632
|
+
} finally {
|
|
633
|
+
if (intentId) deps.connectionIntent?.finish(intentId);
|
|
599
634
|
}
|
|
600
|
-
deps.updateStatus(ctx);
|
|
601
635
|
},
|
|
602
636
|
});
|
|
603
637
|
pi.registerCommand("telegram-disconnect", {
|
|
604
638
|
description: "Stop Telegram and delete current thread in Threaded Mode",
|
|
605
639
|
handler: async (_args, ctx) => {
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (!confirmed) {
|
|
613
|
-
ctx.ui.notify("Telegram disconnect cancelled.", "info");
|
|
614
|
-
deps.updateStatus(ctx);
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
640
|
+
const generation = deps.getSessionGeneration?.();
|
|
641
|
+
const isCurrent = () =>
|
|
642
|
+
(generation === undefined || deps.getSessionGeneration?.() === generation) &&
|
|
643
|
+
deps.isContextCurrent?.(ctx) !== false;
|
|
644
|
+
if (!isCurrent()) return;
|
|
645
|
+
deps.connectionIntent?.cancel();
|
|
618
646
|
try {
|
|
647
|
+
const threadName = deps.getDisconnectThreadName?.();
|
|
648
|
+
if (threadName) {
|
|
649
|
+
const confirmed = await ctx.ui.confirm(
|
|
650
|
+
ctx.ui.theme.fg("accent", "pi-telegram"),
|
|
651
|
+
`Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`,
|
|
652
|
+
);
|
|
653
|
+
if (!isCurrent()) return;
|
|
654
|
+
if (!confirmed) {
|
|
655
|
+
ctx.ui.notify("Telegram disconnect cancelled.", "info");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
619
659
|
const message = await deps.stopPolling();
|
|
660
|
+
if (!isCurrent()) return;
|
|
620
661
|
if (message) ctx.ui.notify(message, "info");
|
|
621
662
|
deps.queueAgentConnectionContext?.(false);
|
|
622
663
|
} catch (error) {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
"warning",
|
|
627
|
-
);
|
|
628
|
-
throw error;
|
|
664
|
+
deps.recordConnectionEvent?.(error, "disconnect");
|
|
665
|
+
if (!isCurrent()) return;
|
|
666
|
+
ctx.ui.notify("Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.", "warning");
|
|
629
667
|
} finally {
|
|
630
|
-
deps.updateStatus(ctx);
|
|
668
|
+
if (isCurrent()) deps.updateStatus(ctx);
|
|
631
669
|
}
|
|
632
670
|
},
|
|
633
671
|
});
|
|
@@ -2440,6 +2478,19 @@ export interface TelegramSessionActionAssemblyDeps {
|
|
|
2440
2478
|
};
|
|
2441
2479
|
getProfileName: () => string | undefined;
|
|
2442
2480
|
ownsPersistence: () => boolean;
|
|
2481
|
+
/**
|
|
2482
|
+
* Registered-follower port. A follower cannot persist leader-owned state, so
|
|
2483
|
+
* its Workspace Thread intent is published and claimed by the leader over
|
|
2484
|
+
* authenticated generation-fenced bus RPC.
|
|
2485
|
+
*/
|
|
2486
|
+
follower?: {
|
|
2487
|
+
instanceId: string;
|
|
2488
|
+
isRegisteredFor: (target: { chatId: number; threadId?: number }) => boolean;
|
|
2489
|
+
requestSessionReplacement: (
|
|
2490
|
+
operation: "publish" | "settle",
|
|
2491
|
+
intent: TelegramSessionReplacementIntent,
|
|
2492
|
+
) => Promise<boolean>;
|
|
2493
|
+
};
|
|
2443
2494
|
sendResult: (
|
|
2444
2495
|
target: { chatId: number; threadId?: number },
|
|
2445
2496
|
html: string,
|
|
@@ -2478,7 +2529,13 @@ export function createTelegramSessionActionAssembly(
|
|
|
2478
2529
|
sendUserMessage: deps.sendUserMessage,
|
|
2479
2530
|
notifyResult(target, result) { return sendTerminalResult(target, result); },
|
|
2480
2531
|
async prepareReplacement(ctx, updateId, target) {
|
|
2481
|
-
|
|
2532
|
+
const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
|
|
2533
|
+
deps.follower?.isRegisteredFor(target)
|
|
2534
|
+
? deps.follower
|
|
2535
|
+
: undefined;
|
|
2536
|
+
// Follower memory is not authority; reread the leader-published snapshot.
|
|
2537
|
+
if (follower && deps.store.refresh) await deps.store.refresh();
|
|
2538
|
+
else await deps.store.load();
|
|
2482
2539
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
2483
2540
|
const binding = typeof target.threadId === "number"
|
|
2484
2541
|
? deps.store.getWorkspaceBindingByTarget(target)
|
|
@@ -2488,7 +2545,7 @@ export function createTelegramSessionActionAssembly(
|
|
|
2488
2545
|
throw new Error("Telegram session replacement binding is unavailable.");
|
|
2489
2546
|
}
|
|
2490
2547
|
const createdAtMs = now();
|
|
2491
|
-
|
|
2548
|
+
const intent: TelegramSessionReplacementIntent = {
|
|
2492
2549
|
continuity: binding ? "workspace-thread" : "classic-chat",
|
|
2493
2550
|
cwd: binding?.cwd ?? ctx.cwd,
|
|
2494
2551
|
profileName: deps.getProfileName() ?? "default",
|
|
@@ -2501,7 +2558,11 @@ export function createTelegramSessionActionAssembly(
|
|
|
2501
2558
|
? { threadName: binding.manualThreadName ?? binding.threadName } : {}),
|
|
2502
2559
|
createdAtMs,
|
|
2503
2560
|
expiresAtMs: createdAtMs + deps.handoffTtlMs,
|
|
2504
|
-
|
|
2561
|
+
...(follower ? { sourceInstanceId: follower.instanceId } : {}),
|
|
2562
|
+
};
|
|
2563
|
+
if (!await (follower
|
|
2564
|
+
? follower.requestSessionReplacement("publish", intent)
|
|
2565
|
+
: deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
|
|
2505
2566
|
throw new Error("Telegram session replacement intent was not persisted.");
|
|
2506
2567
|
}
|
|
2507
2568
|
},
|
|
@@ -2514,11 +2575,20 @@ export function createTelegramSessionActionAssembly(
|
|
|
2514
2575
|
return {
|
|
2515
2576
|
async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
|
|
2516
2577
|
hasSuccessorContinuity(intent) {
|
|
2517
|
-
|
|
2518
|
-
|
|
2578
|
+
if (intent.continuity === "classic-chat") return true;
|
|
2579
|
+
if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
|
|
2580
|
+
intent.cwd) return false;
|
|
2581
|
+
// A follower successor claims only after its own re-registration is live.
|
|
2582
|
+
return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
|
|
2583
|
+
deps.follower?.isRegisteredFor(intent.target) === true;
|
|
2519
2584
|
},
|
|
2520
2585
|
editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
|
|
2521
|
-
clearIntent(intent) {
|
|
2586
|
+
async clearIntent(intent) {
|
|
2587
|
+
if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
|
|
2588
|
+
return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
|
|
2589
|
+
}
|
|
2590
|
+
return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
|
|
2591
|
+
},
|
|
2522
2592
|
profileName: deps.getProfileName() ?? "default",
|
|
2523
2593
|
cwd: ctx.cwd,
|
|
2524
2594
|
sessionId,
|
package/lib/extension.ts
CHANGED
|
@@ -65,6 +65,7 @@ const telegramBusProtocolIdentity =
|
|
|
65
65
|
Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
|
|
66
66
|
Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
|
|
67
67
|
Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
|
|
68
|
+
Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
|
|
68
69
|
]);
|
|
69
70
|
|
|
70
71
|
// --- Extension Runtime ---
|
|
@@ -244,6 +245,20 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
244
245
|
store: threadStore,
|
|
245
246
|
getProfileName: configStore.getActiveProfileName,
|
|
246
247
|
ownsPersistence: lockRuntime.owns,
|
|
248
|
+
follower: {
|
|
249
|
+
instanceId: telegramInstanceId,
|
|
250
|
+
isRegisteredFor(target) {
|
|
251
|
+
const registered = telegramBusFollowerRegistrationState.getTarget();
|
|
252
|
+
return telegramBusFollowerRegistrationState.isRegistered() &&
|
|
253
|
+
registered?.chatId === target.chatId &&
|
|
254
|
+
registered.threadId === target.threadId;
|
|
255
|
+
},
|
|
256
|
+
async requestSessionReplacement(operation, intent) {
|
|
257
|
+
const request = telegramBusFollowerRegistration.requestSessionReplacement;
|
|
258
|
+
if (!request) return false;
|
|
259
|
+
return await request(operation, intent);
|
|
260
|
+
},
|
|
261
|
+
},
|
|
247
262
|
async sendResult(target, html) {
|
|
248
263
|
const delivery = await Delivery.sendTelegramView(
|
|
249
264
|
{ text: html, parseMode: "html", replyMarkup: { inline_keyboard: [] } },
|
|
@@ -1618,6 +1633,27 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1618
1633
|
resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
|
|
1619
1634
|
runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
|
|
1620
1635
|
});
|
|
1636
|
+
const connectionIntent = Lifecycle.createTelegramConnectionIntentRuntime();
|
|
1637
|
+
const connectionLifecycle = Lifecycle.createTelegramConnectionLifecycle({
|
|
1638
|
+
intent: connectionIntent,
|
|
1639
|
+
getGeneration: telegramSessionContextStore.getGeneration,
|
|
1640
|
+
isCurrent: telegramSessionContextStore.isCurrent,
|
|
1641
|
+
getProfileName: configStore.getActiveProfileName,
|
|
1642
|
+
isConnected() {
|
|
1643
|
+
return lockRuntime.owns() || telegramBusFollowerRegistrationState.isRegistered();
|
|
1644
|
+
},
|
|
1645
|
+
async activateProfile(profileName, isCurrent) {
|
|
1646
|
+
await configStore.load();
|
|
1647
|
+
if (!isCurrent()) return false;
|
|
1648
|
+
return configStore.activateProfile(profileName) && configStore.hasBotToken();
|
|
1649
|
+
},
|
|
1650
|
+
start(ctx) {
|
|
1651
|
+
return lockedPollingRuntime.start(ctx);
|
|
1652
|
+
},
|
|
1653
|
+
recordError(error) {
|
|
1654
|
+
recordRuntimeEvent("connection", error, { phase: "resume-connect" });
|
|
1655
|
+
},
|
|
1656
|
+
});
|
|
1621
1657
|
const telegramBridgeSessionLifecycleDeps =
|
|
1622
1658
|
Lifecycle.createTelegramBridgeSessionLifecycleDeps({
|
|
1623
1659
|
contextStore: telegramSessionContextStore,
|
|
@@ -1666,6 +1702,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1666
1702
|
},
|
|
1667
1703
|
delivery: deliveryLifecycleRuntime,
|
|
1668
1704
|
polling: lockedPollingRuntime,
|
|
1705
|
+
connection: connectionLifecycle,
|
|
1669
1706
|
inboundWorker: {
|
|
1670
1707
|
onSessionShutdown: updateAdmissionRuntimeBinding.onSessionShutdown,
|
|
1671
1708
|
},
|
|
@@ -1812,6 +1849,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1812
1849
|
modelContextAvailabilityRuntime.reconcile();
|
|
1813
1850
|
},
|
|
1814
1851
|
getStatusLines,
|
|
1852
|
+
isContextCurrent: telegramSessionContextStore.isCurrent,
|
|
1853
|
+
getSessionGeneration: telegramSessionContextStore.getGeneration,
|
|
1854
|
+
connectionIntent,
|
|
1815
1855
|
buttonActionStore,
|
|
1816
1856
|
sendMarkdownReply,
|
|
1817
1857
|
async sendChannelMarkdownMessage(channel, markdown, options) {
|
package/lib/lifecycle.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram lifecycle hook registration
|
|
2
|
+
* Telegram session lifecycle coordination and hook registration
|
|
3
3
|
* Zones: pi agent lifecycle, telegram session
|
|
4
|
-
*
|
|
4
|
+
* Owns context generations, bounded connect intent across resume, and session sequencing.
|
|
5
|
+
* Transport authority, durable bindings, and queue custody remain with their owners.
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { formatTelegramConnectionFailure } from "./status.ts";
|
|
7
10
|
import * as BusFollower from "./bus-follower.ts";
|
|
8
11
|
import * as Queue from "./queue.ts";
|
|
9
12
|
import * as TextGroups from "./text-groups.ts";
|
|
@@ -157,6 +160,130 @@ export interface TelegramSessionLifecycleHooks {
|
|
|
157
160
|
) => Promise<void>;
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
export interface TelegramConnectionIntent {
|
|
164
|
+
id: string;
|
|
165
|
+
cwd: string;
|
|
166
|
+
profileName?: string;
|
|
167
|
+
}
|
|
168
|
+
interface TelegramConnectionHandoff extends TelegramConnectionIntent {
|
|
169
|
+
pid: number;
|
|
170
|
+
targetSessionFile: string;
|
|
171
|
+
expiresAtMs: number;
|
|
172
|
+
}
|
|
173
|
+
export interface TelegramConnectionHandoffStore {
|
|
174
|
+
pending?: TelegramConnectionHandoff;
|
|
175
|
+
}
|
|
176
|
+
const HANDOFF_KEY = Symbol.for("pi-telegram.connection-resume.v1");
|
|
177
|
+
function processHandoffStore(): TelegramConnectionHandoffStore {
|
|
178
|
+
const globals = globalThis as unknown as Record<symbol, TelegramConnectionHandoffStore>;
|
|
179
|
+
return globals[HANDOFF_KEY] ??= {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface TelegramConnectionLifecycle {
|
|
183
|
+
prepare(event: SessionStartEvent, ctx: ExtensionContext): (() => void) | undefined;
|
|
184
|
+
onSessionShutdown(event: SessionShutdownEvent, ctx: ExtensionContext): void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createTelegramConnectionLifecycle(deps: {
|
|
188
|
+
intent: ReturnType<typeof createTelegramConnectionIntentRuntime>;
|
|
189
|
+
getGeneration(): number;
|
|
190
|
+
isCurrent(ctx: ExtensionContext): boolean;
|
|
191
|
+
getProfileName(): string | undefined;
|
|
192
|
+
isConnected(): boolean;
|
|
193
|
+
activateProfile(profileName: string | undefined, isCurrent: () => boolean): Promise<boolean>;
|
|
194
|
+
start(ctx: ExtensionContext): Promise<{ ok: boolean; message?: string }>;
|
|
195
|
+
recordError(error: unknown): void;
|
|
196
|
+
}): TelegramConnectionLifecycle {
|
|
197
|
+
return {
|
|
198
|
+
onSessionShutdown(event, ctx) {
|
|
199
|
+
deps.intent.suspend({ reason: event.reason, cwd: ctx.cwd,
|
|
200
|
+
targetSessionFile: event.targetSessionFile, connected: deps.isConnected(),
|
|
201
|
+
profileName: deps.getProfileName() });
|
|
202
|
+
},
|
|
203
|
+
prepare(event, ctx) {
|
|
204
|
+
const intent = deps.intent.resume({ reason: event.reason, cwd: ctx.cwd,
|
|
205
|
+
sessionFile: event.reason === "resume" ? ctx.sessionManager.getSessionFile() : undefined });
|
|
206
|
+
if (!intent) return undefined;
|
|
207
|
+
const generation = deps.getGeneration();
|
|
208
|
+
const isCurrent = () => deps.intent.isActive(intent.id) &&
|
|
209
|
+
generation === deps.getGeneration() && deps.isCurrent(ctx);
|
|
210
|
+
return () => {
|
|
211
|
+
// Startup remains extension-owned background work; no captured command ctx crosses resume.
|
|
212
|
+
void (async () => {
|
|
213
|
+
if (!isCurrent()) return;
|
|
214
|
+
if (!await deps.activateProfile(intent.profileName, isCurrent)) {
|
|
215
|
+
if (isCurrent()) ctx.ui.notify("Telegram profile unavailable. Run /telegram-setup.", "warning");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!isCurrent()) return;
|
|
219
|
+
const result = await deps.start(ctx);
|
|
220
|
+
if (!isCurrent()) return;
|
|
221
|
+
if (!result.ok) {
|
|
222
|
+
deps.recordError(new Error(result.message ?? "Telegram resume connection failed."));
|
|
223
|
+
ctx.ui.notify(formatTelegramConnectionFailure(result.message), "warning");
|
|
224
|
+
}
|
|
225
|
+
})().catch((error) => {
|
|
226
|
+
deps.recordError(error);
|
|
227
|
+
if (isCurrent()) ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
|
|
228
|
+
}).finally(() => deps.intent.finish(intent.id));
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function createTelegramConnectionIntentRuntime(options: {
|
|
235
|
+
store?: TelegramConnectionHandoffStore;
|
|
236
|
+
now?: () => number;
|
|
237
|
+
pid?: number;
|
|
238
|
+
} = {}) {
|
|
239
|
+
const store = options.store ?? processHandoffStore();
|
|
240
|
+
const now = options.now ?? Date.now;
|
|
241
|
+
const pid = options.pid ?? process.pid;
|
|
242
|
+
let active: TelegramConnectionIntent | undefined;
|
|
243
|
+
return {
|
|
244
|
+
begin(cwd: string, profileName?: string): string {
|
|
245
|
+
store.pending = undefined;
|
|
246
|
+
active = { id: randomUUID(), cwd, profileName };
|
|
247
|
+
return active.id;
|
|
248
|
+
},
|
|
249
|
+
isActive(id: string): boolean {
|
|
250
|
+
return active?.id === id;
|
|
251
|
+
},
|
|
252
|
+
finish(id: string): void {
|
|
253
|
+
if (active?.id === id) active = undefined;
|
|
254
|
+
},
|
|
255
|
+
cancel(): void {
|
|
256
|
+
active = undefined;
|
|
257
|
+
store.pending = undefined;
|
|
258
|
+
},
|
|
259
|
+
suspend(input: {
|
|
260
|
+
reason: string;
|
|
261
|
+
cwd: string;
|
|
262
|
+
targetSessionFile?: string;
|
|
263
|
+
connected: boolean;
|
|
264
|
+
profileName?: string;
|
|
265
|
+
}): void {
|
|
266
|
+
const intent = active ?? (input.connected
|
|
267
|
+
? { id: randomUUID(), cwd: input.cwd, profileName: input.profileName }
|
|
268
|
+
: undefined);
|
|
269
|
+
active = undefined;
|
|
270
|
+
store.pending = input.reason === "resume" && input.targetSessionFile &&
|
|
271
|
+
intent?.cwd === input.cwd
|
|
272
|
+
? { ...intent, pid, targetSessionFile: input.targetSessionFile, expiresAtMs: now() + 30_000 }
|
|
273
|
+
: undefined;
|
|
274
|
+
},
|
|
275
|
+
resume(input: { reason: string; cwd: string; sessionFile?: string }): TelegramConnectionIntent | undefined {
|
|
276
|
+
const handoff = store.pending;
|
|
277
|
+
store.pending = undefined;
|
|
278
|
+
if (!handoff || handoff.pid !== pid || now() >= handoff.expiresAtMs ||
|
|
279
|
+
input.reason !== "resume" || input.cwd !== handoff.cwd ||
|
|
280
|
+
input.sessionFile !== handoff.targetSessionFile) return undefined;
|
|
281
|
+
active = { id: randomUUID(), cwd: handoff.cwd, profileName: handoff.profileName };
|
|
282
|
+
return { ...active };
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
160
287
|
export interface TelegramSessionContextStore<TContext> {
|
|
161
288
|
get: () => TContext | undefined;
|
|
162
289
|
getGeneration: () => number;
|
|
@@ -250,6 +377,7 @@ export function createTelegramSessionGenerationFence(
|
|
|
250
377
|
}
|
|
251
378
|
|
|
252
379
|
export interface TelegramBridgeSessionServiceRuntime {
|
|
380
|
+
connection?: TelegramConnectionLifecycle;
|
|
253
381
|
resumeGroupedInput(ctx: ExtensionContext): void;
|
|
254
382
|
suspendGroupedInput(): void;
|
|
255
383
|
delivery: {
|
|
@@ -319,6 +447,7 @@ export interface TelegramBridgeSessionLifecyclePorts<
|
|
|
319
447
|
};
|
|
320
448
|
delivery: TelegramBridgeSessionServiceRuntime["delivery"];
|
|
321
449
|
polling: TelegramBridgeSessionServiceRuntime["polling"];
|
|
450
|
+
connection?: TelegramBridgeSessionServiceRuntime["connection"];
|
|
322
451
|
inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
|
|
323
452
|
capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
|
|
324
453
|
queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
|
|
@@ -348,6 +477,7 @@ export function createTelegramBridgeSessionLifecycleDeps<
|
|
|
348
477
|
}),
|
|
349
478
|
delivery: ports.services.delivery,
|
|
350
479
|
polling: ports.services.polling,
|
|
480
|
+
connection: ports.services.connection,
|
|
351
481
|
inboundWorker: ports.services.inboundWorker,
|
|
352
482
|
capabilityMonitor: ports.services.capabilityMonitor,
|
|
353
483
|
queueWatchdog: ports.services.queueWatchdog,
|
|
@@ -371,19 +501,23 @@ export function createTelegramBridgeSessionLifecycleAssembly<
|
|
|
371
501
|
suspendPolling: deps.follower.suspendPolling,
|
|
372
502
|
recordRuntimeEvent: deps.follower.recordRuntimeEvent,
|
|
373
503
|
});
|
|
504
|
+
let preserveTarget = true;
|
|
374
505
|
const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
|
|
375
506
|
...deps.queue,
|
|
376
507
|
isSessionActive,
|
|
377
|
-
stopPolling: suspendForReplacement,
|
|
508
|
+
stopPolling: () => suspendForReplacement(preserveTarget),
|
|
378
509
|
clearPendingMediaGroups: deps.services.suspendGroupedInput,
|
|
379
510
|
});
|
|
380
511
|
const servicesLifecycle: TelegramSessionLifecycleHooks = {
|
|
381
512
|
async onSessionStart(event, ctx) {
|
|
513
|
+
const resumeConnection = deps.services.connection?.prepare(event, ctx);
|
|
382
514
|
await queueLifecycle.onSessionStart(event, ctx);
|
|
383
515
|
if (!isSessionActive(ctx)) return;
|
|
384
516
|
deps.services.resumeGroupedInput(ctx);
|
|
385
517
|
await deps.services.delivery.onSessionStart();
|
|
386
|
-
|
|
518
|
+
if (!isSessionActive(ctx)) return;
|
|
519
|
+
if (resumeConnection) resumeConnection();
|
|
520
|
+
else await deps.services.polling.onSessionStart(event, ctx);
|
|
387
521
|
deps.services.capabilityMonitor.start(ctx);
|
|
388
522
|
deps.services.queueWatchdog.start(ctx);
|
|
389
523
|
},
|
|
@@ -391,6 +525,8 @@ export function createTelegramBridgeSessionLifecycleAssembly<
|
|
|
391
525
|
const generation = deps.contextStore.getGeneration();
|
|
392
526
|
const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
|
|
393
527
|
if (!isCurrent()) return;
|
|
528
|
+
deps.services.connection?.onSessionShutdown(event, ctx);
|
|
529
|
+
preserveTarget = event.reason !== "resume";
|
|
394
530
|
let preserveThread: (() => Promise<void>) | undefined;
|
|
395
531
|
if (event.reason === "quit") {
|
|
396
532
|
try {
|
package/lib/locks.ts
CHANGED
|
@@ -1515,7 +1515,12 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1515
1515
|
ctx,
|
|
1516
1516
|
state.lock,
|
|
1517
1517
|
);
|
|
1518
|
-
if (!isCurrent()
|
|
1518
|
+
if (!isCurrent()) return;
|
|
1519
|
+
if (!restored) {
|
|
1520
|
+
deps.recordRuntimeEvent?.("lock", "Telegram follower auto-connect did not restore this session.",
|
|
1521
|
+
{ phase: "follower-auto-connect-unavailable" });
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1519
1524
|
deps.onTransportAvailabilityChanged?.();
|
|
1520
1525
|
deps.updateStatus(ctx);
|
|
1521
1526
|
deps.recordRuntimeEvent?.(
|
package/lib/model.ts
CHANGED
|
@@ -454,10 +454,8 @@ export function buildTelegramModelSwitchContinuationText<
|
|
|
454
454
|
thinkingLevel?: ScopedTelegramModel<TModel>["thinkingLevel"],
|
|
455
455
|
): string {
|
|
456
456
|
const modelLabel = `${model.provider}/${model.id}`;
|
|
457
|
-
const thinkingSuffix = thinkingLevel
|
|
458
|
-
|
|
459
|
-
: "";
|
|
460
|
-
return `${telegramPrefix} Continue the interrupted previous request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
|
|
457
|
+
const thinkingSuffix = thinkingLevel ? `; thinking: ${thinkingLevel}` : "";
|
|
458
|
+
return `${telegramPrefix} Continue from the last unfinished step. Model: ${modelLabel}${thinkingSuffix}.`;
|
|
461
459
|
}
|
|
462
460
|
|
|
463
461
|
export type TelegramModelSwitchContinuationSource = Pick<
|