pi-project-switcher 0.8.0 → 0.9.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/README.md +2 -2
- package/index.ts +335 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,8 @@ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch be
|
|
|
14
14
|
- persists across reloads (session entry)
|
|
15
15
|
- sets the session display name
|
|
16
16
|
- injects the project path into every agent turn's system prompt, so file operations default to the active project
|
|
17
|
-
- **via the Telegram bridge**: the switch is confirmed in the chat — a short reply with the project, working directory, and session identity, plus buttons (project list, and switch back to the previous project). The confirmation is sent from the new session runtime, so it also works when the switch restores a stored session. No-switch outcomes (already active, cancelled, unknown) are answered in the chat too.
|
|
18
|
-
- **Telegram transport re-arm**: a session-replacing switch from the Telegram bridge
|
|
17
|
+
- **via the Telegram bridge**: the switch is confirmed in the chat — a short reply with the project, working directory, and session identity, plus buttons (project list, and switch back to the previous project). The confirmation is sent from the new session runtime, so it also works when the switch restores a stored session. No-switch outcomes (already active, cancelled, unknown) are answered in the chat too. When the switch released the Telegram transport (see below), the confirmation is **queued until the transport has verifiably re-armed** (fresh same-pid lock + active polling in `~/.pi/agent/tmp/telegram/state.json`, polled for up to 10 s) — a reply produced while the transport is down would be silently dropped. If the re-arm is not confirmed in time, the switcher instead sends a plain warning directly through the Telegram Bot API (token and chat id from `~/.pi/agent/telegram.json`): the switch happened, but the Telegram extension did not reconnect.
|
|
18
|
+
- **Telegram transport re-arm**: a session-replacing switch from the Telegram bridge loses the bridge's transport: pi-telegram stands down on session shutdown, and its reconnect cannot take over the lock across the restored session's different cwd (same-pid locks never go stale; same-process takeover requires matching cwd). The switcher therefore transitions the transport across the switch itself: before the session switch it executes `/telegram-disconnect` (which releases pi-telegram's lock), and after the switch it re-executes `/telegram-connect` from the new session (~3 s delay) — but **only** when the session being left provably owned the connected transport: pi-telegram's lock (`~/.pi/agent/tmp/telegram/owners.json`) must name this process with a fresh heartbeat and a cwd matching the old session. A cancelled switch reconnects immediately. Native switches and switches from sessions that didn't own the bot never touch the transport. The lock file is only read, never modified.
|
|
19
19
|
- **if the project doesn't exist yet**, offers to create the folder and switch to it (confirmation dialog on dialog-capable surfaces; use `/project <name>!` to skip the dialog — e.g. on headless/RPC surfaces). Unsafe names (path segments, `..`, hidden, absolute) are never created.
|
|
20
20
|
- **Session restore** — a machine-local map (`~/.pi/agent/project-switcher-sessions.json`) remembers the most recent session per project. Switching projects returns you to that project's last session; if none exists (or the file is gone), the switch happens in the current session.
|
|
21
21
|
- **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
|
package/index.ts
CHANGED
|
@@ -29,6 +29,10 @@ const HOME = homedir();
|
|
|
29
29
|
const ENTRY_TYPE = "project-switcher-state";
|
|
30
30
|
const SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
|
|
31
31
|
const SESSION_MAP_PATH = join(HOME, ".pi", "agent", "project-switcher-sessions.json");
|
|
32
|
+
const TELEGRAM_TMP_DIR = join(HOME, ".pi", "agent", "tmp", "telegram");
|
|
33
|
+
const TELEGRAM_STATE_PATH = join(TELEGRAM_TMP_DIR, "state.json");
|
|
34
|
+
const TELEGRAM_OWNERS_PATH = join(TELEGRAM_TMP_DIR, "owners.json");
|
|
35
|
+
const TELEGRAM_CONFIG_PATH = join(HOME, ".pi", "agent", "telegram.json");
|
|
32
36
|
|
|
33
37
|
interface Config {
|
|
34
38
|
baseDir: string;
|
|
@@ -344,7 +348,15 @@ function buildTelegramNoChangePrompt(text: string): string {
|
|
|
344
348
|
* bridge's diagnosis guidance.
|
|
345
349
|
*/
|
|
346
350
|
function telegramOwnersPath(): string {
|
|
347
|
-
return
|
|
351
|
+
return telegramPaths.owners ?? TELEGRAM_OWNERS_PATH;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* pi-telegram's runtime state snapshot — written on a scheduler and on
|
|
356
|
+
* status changes; read-only for the switcher.
|
|
357
|
+
*/
|
|
358
|
+
function telegramStatePath(): string {
|
|
359
|
+
return telegramPaths.state ?? TELEGRAM_STATE_PATH;
|
|
348
360
|
}
|
|
349
361
|
|
|
350
362
|
/**
|
|
@@ -356,13 +368,72 @@ function telegramOwnersPath(): string {
|
|
|
356
368
|
const TELEGRAM_OWNERSHIP_FRESH_MS = 10_000;
|
|
357
369
|
|
|
358
370
|
/**
|
|
359
|
-
* Delay before re-dispatching /telegram-connect
|
|
360
|
-
* switch
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
|
|
371
|
+
* Delay before re-dispatching /telegram-connect in the new runtime. The
|
|
372
|
+
* pre-switch /telegram-disconnect already released the lock synchronously,
|
|
373
|
+
* so the connect succeeds unconditionally; the short delay only avoids
|
|
374
|
+
* racing the session-replacement lifecycle observers.
|
|
375
|
+
*/
|
|
376
|
+
const TELEGRAM_REARM_DELAY_MS = 3_000;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Poll interval for the re-arm verification: how often the state snapshot
|
|
380
|
+
* and lock heartbeat are re-read while waiting for the transport to come
|
|
381
|
+
* back after the reconnect dispatch.
|
|
364
382
|
*/
|
|
365
|
-
const
|
|
383
|
+
const TELEGRAM_REARM_POLL_MS = 500;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Bound for the re-arm verification, measured from the moment the
|
|
387
|
+
* /telegram-connect dispatch completes. When the transport has not
|
|
388
|
+
* verifiably re-armed within this window, the queued confirmation falls
|
|
389
|
+
* back to the direct Bot-API warning (its follow-up reply would be
|
|
390
|
+
* undeliverable anyway while the transport is down).
|
|
391
|
+
*/
|
|
392
|
+
const TELEGRAM_REARM_BOUND_MS = 10_000;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Test seam: paths for the re-arm verification reads. Tests point these
|
|
396
|
+
* at fixture files; production code always uses the real paths.
|
|
397
|
+
*/
|
|
398
|
+
export const telegramPaths: { state?: string; owners?: string } = {};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Test seam: Bot-API warning sender override. When set, the fallback uses
|
|
402
|
+
* this instead of reading telegram.json and calling the Telegram HTTPS
|
|
403
|
+
* API. Signature: (text) => Promise<void> (may throw; callers guard).
|
|
404
|
+
*/
|
|
405
|
+
export let botApiWarningSender: ((text: string) => Promise<void>) | null = null;
|
|
406
|
+
|
|
407
|
+
export function setBotApiWarningSender(sender: ((text: string) => Promise<void>) | null): void {
|
|
408
|
+
botApiWarningSender = sender;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Release the Telegram transport from the OLD (owning) runtime, before
|
|
413
|
+
* the session is replaced. pi's prompt() executes extension commands
|
|
414
|
+
* synchronously, so when this returns, pi-telegram has stopped polling and
|
|
415
|
+
* released the lock (deleted the owners.json entry). Only the owning
|
|
416
|
+
* runtime can release (release() is ownership-guarded), which is exactly
|
|
417
|
+
* why this must run before ctx.switchSession() — and why the probe must
|
|
418
|
+
* pass first. Threaded Mode is disabled for this bot, so no confirmation
|
|
419
|
+
* dialog is raised in the headless daemon.
|
|
420
|
+
*/
|
|
421
|
+
async function releaseTelegramTransportBeforeSwitch(pi: any, source: string): Promise<boolean> {
|
|
422
|
+
try {
|
|
423
|
+
await pi.sendUserMessage("/telegram-disconnect", {
|
|
424
|
+
expandPromptTemplates: true,
|
|
425
|
+
});
|
|
426
|
+
return true;
|
|
427
|
+
} catch (err: any) {
|
|
428
|
+
try {
|
|
429
|
+
pi.notify?.(`Telegram release before ${source} switch failed: ${err?.message ?? err}`, "warning");
|
|
430
|
+
} catch {
|
|
431
|
+
// Non-fatal: the switch proceeds; a later manual /telegram-connect
|
|
432
|
+
// still works because we never mutated anything.
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
366
437
|
|
|
367
438
|
/**
|
|
368
439
|
* Read-only probe: is the session we are about to leave the live owner of
|
|
@@ -397,37 +468,213 @@ function probeTelegramTransportOwnership(oldCwd: string): boolean {
|
|
|
397
468
|
}
|
|
398
469
|
|
|
399
470
|
/**
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
471
|
+
* Read-only re-arm verification: has the transport verifiably come back in
|
|
472
|
+
* THIS process after the reconnect dispatch? True iff (a) the ownership
|
|
473
|
+
* lock has a fresh entry for the current process (the connect handler
|
|
474
|
+
* acquires the lock synchronously) and (b) pi-telegram's state snapshot
|
|
475
|
+
* shows polling as active (or is not yet readable — the snapshot is
|
|
476
|
+
* written on a scheduler, so the fresh lock heartbeat is the primary
|
|
477
|
+
* signal and the snapshot must not contradict it with polling stopped).
|
|
478
|
+
*/
|
|
479
|
+
function probeTelegramRearmConfirmed(): boolean {
|
|
480
|
+
// (a) fresh same-pid lock entry (cwd irrelevant: the new runtime has the
|
|
481
|
+
// new session's cwd by design — the re-arm is exactly the ownership
|
|
482
|
+
// handoff across that cwd change).
|
|
483
|
+
let ownsFreshLock = false;
|
|
484
|
+
try {
|
|
485
|
+
const raw = JSON.parse(readFileSync(telegramOwnersPath(), "utf8"));
|
|
486
|
+
if (raw && typeof raw === "object") {
|
|
487
|
+
for (const entry of Object.values(raw) as any[]) {
|
|
488
|
+
if (
|
|
489
|
+
entry &&
|
|
490
|
+
typeof entry === "object" &&
|
|
491
|
+
entry.pid === process.pid &&
|
|
492
|
+
typeof entry.heartbeatMs === "number" &&
|
|
493
|
+
Date.now() - entry.heartbeatMs <= TELEGRAM_OWNERSHIP_FRESH_MS
|
|
494
|
+
) {
|
|
495
|
+
ownsFreshLock = true;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
return false; // missing/malformed lock: not re-armed
|
|
502
|
+
}
|
|
503
|
+
if (!ownsFreshLock) return false;
|
|
504
|
+
|
|
505
|
+
// (b) state snapshot: polling must not be stopped. A missing/unreadable
|
|
506
|
+
// snapshot does not block confirmation (the lock is authoritative for
|
|
507
|
+
// ownership; polling starts with the connect).
|
|
508
|
+
try {
|
|
509
|
+
const raw = JSON.parse(readFileSync(telegramStatePath(), "utf8"));
|
|
510
|
+
const pollingActive = raw?.runtime?.pollingActive;
|
|
511
|
+
if (typeof pollingActive === "boolean" && !pollingActive) {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
// snapshot absent or stale: rely on the lock heartbeat alone
|
|
516
|
+
}
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Bounded wait for the verified re-arm. Polls the lock + state snapshot at
|
|
522
|
+
* a short interval for up to TELEGRAM_REARM_BOUND_MS. Never throws.
|
|
523
|
+
*/
|
|
524
|
+
async function waitForTelegramRearm(): Promise<{ confirmed: boolean; reason?: string }> {
|
|
525
|
+
const deadline = Date.now() + TELEGRAM_REARM_BOUND_MS;
|
|
526
|
+
for (;;) {
|
|
527
|
+
if (probeTelegramRearmConfirmed()) {
|
|
528
|
+
return { confirmed: true };
|
|
529
|
+
}
|
|
530
|
+
if (Date.now() >= deadline) {
|
|
531
|
+
return {
|
|
532
|
+
confirmed: false,
|
|
533
|
+
reason: "telegram transport did not re-arm within 10s of the reconnect dispatch",
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
await new Promise((resolve) => setTimeout(resolve, TELEGRAM_REARM_POLL_MS));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Direct Telegram Bot-API warning fallback. Reads the bot token and the
|
|
542
|
+
* allowed user id from ~/.pi/agent/telegram.json and sends one plain
|
|
543
|
+
* message via the public HTTPS API — usable precisely when the extension
|
|
544
|
+
* transport is dead. Never throws; failures are returned as a reason so
|
|
545
|
+
* the caller can journal them. `project` names the switched-to project.
|
|
546
|
+
*/
|
|
547
|
+
async function sendBotApiRearmWarning(project: string): Promise<string | null> {
|
|
548
|
+
if (botApiWarningSender) {
|
|
549
|
+
try {
|
|
550
|
+
await botApiWarningSender(`⚠️ Switched to ${project}, but reconnecting the pi Telegram extension did not work — Telegram commands may not reach the agent until it reconnects (/telegram-connect).`);
|
|
551
|
+
return null;
|
|
552
|
+
} catch (err: any) {
|
|
553
|
+
return `Bot-API warning send failed: ${err?.message ?? err}`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Read token + chat id from the bridge configuration.
|
|
558
|
+
let token = "";
|
|
559
|
+
let chatId: number | undefined;
|
|
560
|
+
try {
|
|
561
|
+
const raw = JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, "utf8"));
|
|
562
|
+
const profile = raw?.profiles?.default ?? raw;
|
|
563
|
+
token = typeof profile?.botToken === "string" ? profile.botToken : "";
|
|
564
|
+
chatId = typeof profile?.allowedUserId === "number" ? profile.allowedUserId : undefined;
|
|
565
|
+
} catch {
|
|
566
|
+
return "Bot-API warning skipped: ~/.pi/agent/telegram.json unreadable";
|
|
567
|
+
}
|
|
568
|
+
if (!token || chatId === undefined) {
|
|
569
|
+
return "Bot-API warning skipped: bot token or allowed user id missing";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const text =
|
|
573
|
+
`⚠️ Switched to ${project}, but reconnecting the pi Telegram extension did not work ` +
|
|
574
|
+
`— Telegram commands may not reach the agent until it reconnects (/telegram-connect).`;
|
|
575
|
+
try {
|
|
576
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
577
|
+
method: "POST",
|
|
578
|
+
headers: { "Content-Type": "application/json" },
|
|
579
|
+
body: JSON.stringify({ chat_id: chatId, text }),
|
|
580
|
+
});
|
|
581
|
+
if (!response.ok) {
|
|
582
|
+
return `Bot-API warning failed: HTTP ${response.status}`;
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
} catch (err: any) {
|
|
586
|
+
return `Bot-API warning send failed: ${err?.message ?? err}`;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
interface QueuedTelegramFollowUp {
|
|
591
|
+
/** Prompt for the confirmation follow-up turn (built at queue time). */
|
|
592
|
+
prompt: string;
|
|
593
|
+
/** Project name, for the Bot-API fallback warning. */
|
|
594
|
+
project: string;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Pending combined re-arm callback (single slot): scheduling a new one
|
|
599
|
+
* supersedes a not-yet-fired previous one. The callback only touches the
|
|
600
|
+
* withSession context it was scheduled with, so it can never act on a
|
|
601
|
+
* stale runtime.
|
|
403
602
|
*/
|
|
404
603
|
let pendingRearmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
405
604
|
|
|
406
|
-
function
|
|
605
|
+
function clearPendingRearm(): void {
|
|
407
606
|
if (pendingRearmTimer) {
|
|
408
607
|
clearTimeout(pendingRearmTimer);
|
|
409
608
|
pendingRearmTimer = null;
|
|
410
609
|
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Schedule the transport re-arm from the fresh runtime: after the safety
|
|
614
|
+
* delay, re-dispatch /telegram-connect, wait until the re-arm is VERIFIED
|
|
615
|
+
* (bounded), then dispatch the queued confirmation follow-up. When the
|
|
616
|
+
* re-arm does not confirm within the bound (or the connect dispatch
|
|
617
|
+
* fails), the confirmation turn is NOT dispatched (its reply would be
|
|
618
|
+
* undeliverable); instead a plain warning goes out directly through the
|
|
619
|
+
* Telegram Bot API and the failure is journaled locally. Every step is
|
|
620
|
+
* guarded: an error inside this timer callback must never kill the daemon.
|
|
621
|
+
*/
|
|
622
|
+
function scheduleTelegramRearm(
|
|
623
|
+
newCtx: any,
|
|
624
|
+
source: string,
|
|
625
|
+
queuedFollowUp: QueuedTelegramFollowUp | null,
|
|
626
|
+
): void {
|
|
627
|
+
clearPendingRearm();
|
|
411
628
|
const timer = setTimeout(async () => {
|
|
412
629
|
pendingRearmTimer = null;
|
|
630
|
+
const journal = (message: string) => {
|
|
631
|
+
try {
|
|
632
|
+
newCtx.ui.notify(message, "warning");
|
|
633
|
+
} catch {
|
|
634
|
+
// The fresh context can be gone (e.g. another switch followed):
|
|
635
|
+
// never let an error escape into an uncaught timer callback.
|
|
636
|
+
}
|
|
637
|
+
};
|
|
413
638
|
try {
|
|
414
639
|
// Command re-dispatch from the FRESH runtime: executes pi-telegram's
|
|
415
|
-
// connect handler (no agent turn)
|
|
416
|
-
//
|
|
640
|
+
// connect handler (no agent turn). The lock was released by the
|
|
641
|
+
// pre-switch disconnect, so the acquire succeeds unconditionally —
|
|
642
|
+
// no staleness wait, no takeover dialog (same-pid locks never go
|
|
643
|
+
// stale, and same-process takeover cannot cross a cwd mismatch).
|
|
417
644
|
await newCtx.sendUserMessage("/telegram-connect", {
|
|
418
645
|
expandPromptTemplates: true,
|
|
419
646
|
});
|
|
420
647
|
} catch (err: any) {
|
|
648
|
+
journal(`Telegram re-arm after ${source} switch failed: ${err?.message ?? err}`);
|
|
649
|
+
if (queuedFollowUp) {
|
|
650
|
+
const warnError = await sendBotApiRearmWarning(queuedFollowUp.project);
|
|
651
|
+
if (warnError) journal(warnError);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!queuedFollowUp) return;
|
|
657
|
+
|
|
658
|
+
// Wait for the verified re-arm before dispatching the confirmation:
|
|
659
|
+
// a follow-up reply finishing while the transport is still down is
|
|
660
|
+
// dropped silently by pi-telegram (observed 2026-09-22).
|
|
661
|
+
const rearm = await waitForTelegramRearm();
|
|
662
|
+
if (rearm.confirmed) {
|
|
421
663
|
try {
|
|
422
|
-
newCtx.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// Even the fresh context can be gone (e.g. another switch followed):
|
|
428
|
-
// never let an error escape into an uncaught timer callback.
|
|
664
|
+
await newCtx.sendUserMessage(queuedFollowUp.prompt, {
|
|
665
|
+
deliverAs: "followUp",
|
|
666
|
+
});
|
|
667
|
+
} catch (err: any) {
|
|
668
|
+
journal(`Telegram confirmation after ${source} switch failed: ${err?.message ?? err}`);
|
|
429
669
|
}
|
|
670
|
+
return;
|
|
430
671
|
}
|
|
672
|
+
|
|
673
|
+
// Re-arm not verifiable in time: the follow-up reply would be
|
|
674
|
+
// undeliverable — warn directly through the Bot API instead.
|
|
675
|
+
journal(`Telegram re-arm verification after ${source} switch timed out: ${rearm.reason}`);
|
|
676
|
+
const warnError = await sendBotApiRearmWarning(queuedFollowUp.project);
|
|
677
|
+
if (warnError) journal(warnError);
|
|
431
678
|
}, TELEGRAM_REARM_DELAY_MS);
|
|
432
679
|
timer.unref?.();
|
|
433
680
|
pendingRearmTimer = timer;
|
|
@@ -684,15 +931,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
684
931
|
// with "stale ctx" errors and killed the whole flow).
|
|
685
932
|
const branch = getGitBranch(projectPath(name));
|
|
686
933
|
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
934
|
+
const confirmPrompt = buildTelegramSwitchConfirmationPrompt({
|
|
935
|
+
project: name,
|
|
936
|
+
path: projectPath(name),
|
|
937
|
+
branchStr,
|
|
938
|
+
sessionLine: `🗂 Session restored: ${basename(targetSession)}`,
|
|
939
|
+
previous,
|
|
940
|
+
});
|
|
687
941
|
// Ownership probe BEFORE the switch (the old session's cwd is only
|
|
688
942
|
// available here): when the session we are leaving is the live owner
|
|
689
|
-
// of the connected Telegram transport, the
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
943
|
+
// of the connected Telegram transport, the transport must be carried
|
|
944
|
+
// across the replacement: the old runtime releases it now (the only
|
|
945
|
+
// runtime that CAN — release() is ownership-guarded), and the new
|
|
946
|
+
// runtime re-arms it in withSession. Without the release, the lock
|
|
947
|
+
// entry would stay forever active-here (same-pid locks never go
|
|
948
|
+
// stale) and the connect could never re-acquire across the cwd
|
|
949
|
+
// change. The probe failing means we are NOT the owner (another pi
|
|
950
|
+
// instance, or Telegram was never connected here): touch nothing.
|
|
694
951
|
const ownedTelegramTransport =
|
|
695
952
|
isTelegramOrigin && probeTelegramTransportOwnership(ctx.cwd);
|
|
953
|
+
let releasedTelegramTransport = false;
|
|
954
|
+
if (ownedTelegramTransport) {
|
|
955
|
+
// Must run while the old runtime is still current, i.e. before
|
|
956
|
+
// ctx.switchSession(). Executes /telegram-disconnect synchronously
|
|
957
|
+
// (pi runs extension commands before any queue/streaming logic).
|
|
958
|
+
releasedTelegramTransport = await releaseTelegramTransportBeforeSwitch(pi, name);
|
|
959
|
+
}
|
|
696
960
|
const result = await ctx.switchSession(targetSession, {
|
|
697
961
|
withSession: async (newCtx: any) => {
|
|
698
962
|
// Safety net in case the restored session has no project entry:
|
|
@@ -706,29 +970,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
706
970
|
`Workdir: ${projectPath(name)}${branchStr ? ` ${branchStr}` : ""}`,
|
|
707
971
|
"info"
|
|
708
972
|
);
|
|
709
|
-
if (isTelegramOrigin) {
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
973
|
+
if (isTelegramOrigin && releasedTelegramTransport) {
|
|
974
|
+
// The pre-switch disconnect released the transport; the new
|
|
975
|
+
// runtime re-arms it after a short safety delay. The
|
|
976
|
+
// confirmation turn must NOT be dispatched until the re-arm is
|
|
977
|
+
// VERIFIED: a follow-up reply finishing while the transport is
|
|
978
|
+
// still down is dropped silently by pi-telegram (observed
|
|
979
|
+
// 2026-09-22). Queue it with the re-arm: connect → verified
|
|
980
|
+
// polling → confirmation from this fresh context; on timeout,
|
|
981
|
+
// a plain warning goes out directly via the Telegram Bot API.
|
|
982
|
+
scheduleTelegramRearm(newCtx, name, { prompt: confirmPrompt, project: name });
|
|
983
|
+
} else if (isTelegramOrigin) {
|
|
984
|
+
// No transport transition (probe failed): the transport was
|
|
985
|
+
// never released, so there is no dead window — the
|
|
986
|
+
// confirmation turn is safe immediately. The turn also settles
|
|
987
|
+
// pi-telegram's dispatch queue.
|
|
714
988
|
await newCtx.waitForIdle();
|
|
715
|
-
await newCtx.sendUserMessage(
|
|
716
|
-
buildTelegramSwitchConfirmationPrompt({
|
|
717
|
-
project: name,
|
|
718
|
-
path: projectPath(name),
|
|
719
|
-
branchStr,
|
|
720
|
-
sessionLine: `🗂 Session restored: ${basename(targetSession)}`,
|
|
721
|
-
previous,
|
|
722
|
-
}),
|
|
723
|
-
{ deliverAs: "followUp" }
|
|
724
|
-
);
|
|
989
|
+
await newCtx.sendUserMessage(confirmPrompt, { deliverAs: "followUp" });
|
|
725
990
|
}
|
|
726
|
-
if (
|
|
727
|
-
// Re-arm the Telegram transport from the fresh runtime
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
|
|
991
|
+
if (releasedTelegramTransport) {
|
|
992
|
+
// Re-arm the Telegram transport from the fresh runtime (queued
|
|
993
|
+
// together with the confirmation above when both apply). The
|
|
994
|
+
// lock was released by the pre-switch disconnect, so this
|
|
995
|
+
// connect acquires unconditionally after the short safety
|
|
996
|
+
// delay. Only the withSession context is touched.
|
|
997
|
+
if (!isTelegramOrigin) {
|
|
998
|
+
scheduleTelegramRearm(newCtx, name, null);
|
|
999
|
+
}
|
|
732
1000
|
}
|
|
733
1001
|
},
|
|
734
1002
|
});
|
|
@@ -738,6 +1006,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
738
1006
|
// attempted; an explicit later status/switch overrides it.
|
|
739
1007
|
activeProject = previous;
|
|
740
1008
|
ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
|
|
1009
|
+
if (releasedTelegramTransport) {
|
|
1010
|
+
// The pre-switch disconnect released the transport for the
|
|
1011
|
+
// replacement that is now NOT happening: reconnect from this
|
|
1012
|
+
// (still-current) runtime so the cancelled switch leaves the
|
|
1013
|
+
// transport in its original state. No lock remains, so the
|
|
1014
|
+
// connect re-acquires unconditionally.
|
|
1015
|
+
try {
|
|
1016
|
+
await pi.sendUserMessage("/telegram-connect", {
|
|
1017
|
+
expandPromptTemplates: true,
|
|
1018
|
+
});
|
|
1019
|
+
} catch (err: any) {
|
|
1020
|
+
ctx.ui.notify(
|
|
1021
|
+
`Telegram reconnect after cancelled switch failed: ${err?.message ?? err}`,
|
|
1022
|
+
"warning"
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
741
1026
|
if (isTelegramOrigin) {
|
|
742
1027
|
await ctx.waitForIdle();
|
|
743
1028
|
pi.sendUserMessage(
|
|
@@ -873,3 +1158,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
873
1158
|
},
|
|
874
1159
|
});
|
|
875
1160
|
}
|
|
1161
|
+
|
|
1162
|
+
// ── Test-only hooks (never imported in production) ────────────────────────
|
|
1163
|
+
export const __testProbeRearm = probeTelegramRearmConfirmed;
|
|
1164
|
+
export const __testClearRearm = clearPendingRearm;
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":
|
|
1
|
+
{"name":"pi-project-switcher","version":"0.9.0","description":"pi coding agent extension: switch between projects under a configurable base directory via /project","main":"index.ts","type":"module","scripts":{"test":"vitest run","test:watch":"vitest","typecheck":"tsc --noEmit"},"keywords":["pi","pi-package","pi-extension","project","switcher","project-switching"],"author":"stefclawd","license":"MIT","repository":{"type":"git","url":"git+https://github.com/stefclawd/pi-project-switcher.git"},"bugs":{"url":"https://github.com/stefclawd/pi-project-switcher/issues"},"homepage":"https://github.com/stefclawd/pi-project-switcher#readme","files":["index.ts","README.md","LICENSE"],"engines":{"node":">=22.19.0"},"pi":{"extensions":["./index.ts"]},"devDependencies":{"@earendil-works/pi-coding-agent":"^0.85.1","@types/node":"^24.0.0","typescript":"^5.7.0","vitest":"^3.0.0"}}
|