pi-project-switcher 0.8.1 → 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 +1 -1
  2. package/index.ts +266 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,7 +14,7 @@ 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.
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
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.
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
  /**
@@ -363,6 +375,39 @@ const TELEGRAM_OWNERSHIP_FRESH_MS = 10_000;
363
375
  */
364
376
  const TELEGRAM_REARM_DELAY_MS = 3_000;
365
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.
382
+ */
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
+
366
411
  /**
367
412
  * Release the Telegram transport from the OLD (owning) runtime, before
368
413
  * the session is replaced. pi's prompt() executes extension commands
@@ -423,19 +468,173 @@ function probeTelegramTransportOwnership(oldCwd: string): boolean {
423
468
  }
424
469
 
425
470
  /**
426
- * Pending re-arm timer (single slot): scheduling a new re-arm supersedes
427
- * a not-yet-fired previous one. The callback only touches the withSession
428
- * 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.
429
602
  */
430
603
  let pendingRearmTimer: ReturnType<typeof setTimeout> | null = null;
431
604
 
432
- function scheduleTelegramRearm(newCtx: any, source: string): void {
605
+ function clearPendingRearm(): void {
433
606
  if (pendingRearmTimer) {
434
607
  clearTimeout(pendingRearmTimer);
435
608
  pendingRearmTimer = null;
436
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();
437
628
  const timer = setTimeout(async () => {
438
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
+ };
439
638
  try {
440
639
  // Command re-dispatch from the FRESH runtime: executes pi-telegram's
441
640
  // connect handler (no agent turn). The lock was released by the
@@ -446,16 +645,36 @@ function scheduleTelegramRearm(newCtx: any, source: string): void {
446
645
  expandPromptTemplates: true,
447
646
  });
448
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) {
449
663
  try {
450
- newCtx.ui.notify(
451
- `Telegram re-arm after ${source} switch failed: ${err?.message ?? err}`,
452
- "warning"
453
- );
454
- } catch {
455
- // Even the fresh context can be gone (e.g. another switch followed):
456
- // 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}`);
457
669
  }
670
+ return;
458
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);
459
678
  }, TELEGRAM_REARM_DELAY_MS);
460
679
  timer.unref?.();
461
680
  pendingRearmTimer = timer;
@@ -712,6 +931,13 @@ export default function (pi: ExtensionAPI) {
712
931
  // with "stale ctx" errors and killed the whole flow).
713
932
  const branch = getGitBranch(projectPath(name));
714
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
+ });
715
941
  // Ownership probe BEFORE the switch (the old session's cwd is only
716
942
  // available here): when the session we are leaving is the live owner
717
943
  // of the connected Telegram transport, the transport must be carried
@@ -744,29 +970,33 @@ export default function (pi: ExtensionAPI) {
744
970
  `Workdir: ${projectPath(name)}${branchStr ? ` ${branchStr}` : ""}`,
745
971
  "info"
746
972
  );
747
- if (isTelegramOrigin) {
748
- // Confirmation turn from the FRESH runtime: the reply reaches
749
- // the Telegram chat (the local notify above never does). Only
750
- // the new context is touched — the pre-switch pi/ctx are stale.
751
- // 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.
752
988
  await newCtx.waitForIdle();
753
- await newCtx.sendUserMessage(
754
- buildTelegramSwitchConfirmationPrompt({
755
- project: name,
756
- path: projectPath(name),
757
- branchStr,
758
- sessionLine: `🗂 Session restored: ${basename(targetSession)}`,
759
- previous,
760
- }),
761
- { deliverAs: "followUp" }
762
- );
989
+ await newCtx.sendUserMessage(confirmPrompt, { deliverAs: "followUp" });
763
990
  }
764
991
  if (releasedTelegramTransport) {
765
- // Re-arm the Telegram transport from the fresh runtime. The lock
766
- // was released by the pre-switch disconnect, so this connect
767
- // acquires unconditionally after the short safety delay. Only
768
- // the withSession context is touched (see scheduleTelegramRearm).
769
- scheduleTelegramRearm(newCtx, name);
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
+ }
770
1000
  }
771
1001
  },
772
1002
  });
@@ -928,3 +1158,7 @@ export default function (pi: ExtensionAPI) {
928
1158
  },
929
1159
  });
930
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.1", "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"}}