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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/index.ts +335 -46
  3. 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 suspends the bridge's polling (pi-telegram stands down on session shutdown and its auto-reconnect declines the handoff because the restored session's cwd differs from the lock's cwd). The switcher therefore re-runs `/telegram-connect` from the new session ~9 s after the switch — 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. Native switches and switches from sessions that didn't own the bot never re-arm. The lock file is only read, never modified.
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 join(HOME, ".pi", "agent", "tmp", "telegram", "owners.json");
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 after a session-replacing
360
- * switch. Must exceed pi-telegram's lock staleness window (8s): the
361
- * suspended old runtime stops refreshing the heartbeat at session
362
- * shutdown, and a *stale* lock is the only one the connect handler's
363
- * non-forced acquire can replace across a cwd mismatch.
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 TELEGRAM_REARM_DELAY_MS = 9_000;
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
- * Pending re-arm timer (single slot): scheduling a new re-arm supersedes
401
- * a not-yet-fired previous one. The callback only touches the withSession
402
- * context it was scheduled with, so it can never act on a stale runtime.
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 scheduleTelegramRearm(newCtx: any, source: string): void {
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) and re-acquires the now-stale lock,
416
- // restarting polling in the new session.
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.ui.notify(
423
- `Telegram re-arm after ${source} switch failed: ${err?.message ?? err}`,
424
- "warning"
425
- );
426
- } catch {
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 new runtime must re-arm
690
- // it after the switch (pi-telegram suspends polling on session
691
- // shutdown and its auto-start declines the handoff across a cwd
692
- // mismatch). The probe failing means we are NOT the owner (another
693
- // pi instance, or Telegram was never connected here): re-arm nothing.
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
- // Confirmation turn from the FRESH runtime: the reply reaches
711
- // the Telegram chat (the local notify above never does). Only
712
- // the new context is touched — the pre-switch pi/ctx are stale.
713
- // The turn also settles pi-telegram's dispatch queue.
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 (ownedTelegramTransport) {
727
- // Re-arm the Telegram transport from the fresh runtime. Only the
728
- // withSession context is touched; the delay lets the old lock
729
- // go stale (see TELEGRAM_REARM_DELAY_MS) so the connect
730
- // handler's acquire can replace it across the cwd mismatch.
731
- scheduleTelegramRearm(newCtx, name);
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": "pi-project-switcher", "version": "0.8.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"}}
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"}}