pi-project-switcher 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/index.ts +168 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,6 +15,7 @@ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch be
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
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 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.
18
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.
19
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.
20
21
  - **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
package/index.ts CHANGED
@@ -335,6 +335,131 @@ function buildTelegramNoChangePrompt(text: string): string {
335
335
  );
336
336
  }
337
337
 
338
+ // ── Telegram transport ownership probe & re-arm ──────────────────────────────
339
+
340
+ /**
341
+ * Path of pi-telegram's transport ownership lock. The switcher only ever
342
+ * READS this file (pi-telegram's own connect handler performs the actual
343
+ * acquisition); mutating it by hand is explicitly forbidden by the
344
+ * bridge's diagnosis guidance.
345
+ */
346
+ function telegramOwnersPath(): string {
347
+ return join(HOME, ".pi", "agent", "tmp", "telegram", "owners.json");
348
+ }
349
+
350
+ /**
351
+ * Freshness bound for the ownership probe. Deliberately LOOSER than
352
+ * pi-telegram's 8s staleness window: a passing probe means the owner
353
+ * runtime was definitely alive at switch time (a false positive here only
354
+ * leads to a connect attempt that pi-telegram itself re-validates).
355
+ */
356
+ const TELEGRAM_OWNERSHIP_FRESH_MS = 10_000;
357
+
358
+ /**
359
+ * Delay before re-dispatching /telegram-connect in the new runtime. The
360
+ * pre-switch /telegram-disconnect already released the lock synchronously,
361
+ * so the connect succeeds unconditionally; the short delay only avoids
362
+ * racing the session-replacement lifecycle observers.
363
+ */
364
+ const TELEGRAM_REARM_DELAY_MS = 3_000;
365
+
366
+ /**
367
+ * Release the Telegram transport from the OLD (owning) runtime, before
368
+ * the session is replaced. pi's prompt() executes extension commands
369
+ * synchronously, so when this returns, pi-telegram has stopped polling and
370
+ * released the lock (deleted the owners.json entry). Only the owning
371
+ * runtime can release (release() is ownership-guarded), which is exactly
372
+ * why this must run before ctx.switchSession() — and why the probe must
373
+ * pass first. Threaded Mode is disabled for this bot, so no confirmation
374
+ * dialog is raised in the headless daemon.
375
+ */
376
+ async function releaseTelegramTransportBeforeSwitch(pi: any, source: string): Promise<boolean> {
377
+ try {
378
+ await pi.sendUserMessage("/telegram-disconnect", {
379
+ expandPromptTemplates: true,
380
+ });
381
+ return true;
382
+ } catch (err: any) {
383
+ try {
384
+ pi.notify?.(`Telegram release before ${source} switch failed: ${err?.message ?? err}`, "warning");
385
+ } catch {
386
+ // Non-fatal: the switch proceeds; a later manual /telegram-connect
387
+ // still works because we never mutated anything.
388
+ }
389
+ return false;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Read-only probe: is the session we are about to leave the live owner of
395
+ * the connected Telegram transport? True iff pi-telegram's lock file has
396
+ * an entry for the current process whose heartbeat is fresh and whose cwd
397
+ * matches the old session's cwd (or is absent). This is the guard that
398
+ * prevents stealing the bot from a different pi instance or re-arming in
399
+ * a session that never used Telegram.
400
+ */
401
+ function probeTelegramTransportOwnership(oldCwd: string): boolean {
402
+ try {
403
+ const raw = JSON.parse(readFileSync(telegramOwnersPath(), "utf8"));
404
+ if (!raw || typeof raw !== "object") return false;
405
+ // Any profile entry counts (the default profile uses the key "default").
406
+ for (const entry of Object.values(raw) as any[]) {
407
+ if (
408
+ entry &&
409
+ typeof entry === "object" &&
410
+ entry.pid === process.pid &&
411
+ typeof entry.heartbeatMs === "number" &&
412
+ Date.now() - entry.heartbeatMs <= TELEGRAM_OWNERSHIP_FRESH_MS &&
413
+ (entry.cwd === undefined || entry.cwd === oldCwd)
414
+ ) {
415
+ return true;
416
+ }
417
+ }
418
+ return false;
419
+ } catch {
420
+ // Missing or malformed lock file: transport not owned here.
421
+ return false;
422
+ }
423
+ }
424
+
425
+ /**
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.
429
+ */
430
+ let pendingRearmTimer: ReturnType<typeof setTimeout> | null = null;
431
+
432
+ function scheduleTelegramRearm(newCtx: any, source: string): void {
433
+ if (pendingRearmTimer) {
434
+ clearTimeout(pendingRearmTimer);
435
+ pendingRearmTimer = null;
436
+ }
437
+ const timer = setTimeout(async () => {
438
+ pendingRearmTimer = null;
439
+ try {
440
+ // Command re-dispatch from the FRESH runtime: executes pi-telegram's
441
+ // connect handler (no agent turn). The lock was released by the
442
+ // pre-switch disconnect, so the acquire succeeds unconditionally —
443
+ // no staleness wait, no takeover dialog (same-pid locks never go
444
+ // stale, and same-process takeover cannot cross a cwd mismatch).
445
+ await newCtx.sendUserMessage("/telegram-connect", {
446
+ expandPromptTemplates: true,
447
+ });
448
+ } catch (err: any) {
449
+ 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.
457
+ }
458
+ }
459
+ }, TELEGRAM_REARM_DELAY_MS);
460
+ timer.unref?.();
461
+ pendingRearmTimer = timer;
462
+ }
338
463
 
339
464
  function getConfig(): Config {
340
465
  if (!config) {
@@ -587,6 +712,25 @@ export default function (pi: ExtensionAPI) {
587
712
  // with "stale ctx" errors and killed the whole flow).
588
713
  const branch = getGitBranch(projectPath(name));
589
714
  const branchStr = branch ? ` on branch \`${branch}\`` : "";
715
+ // Ownership probe BEFORE the switch (the old session's cwd is only
716
+ // available here): when the session we are leaving is the live owner
717
+ // of the connected Telegram transport, the transport must be carried
718
+ // across the replacement: the old runtime releases it now (the only
719
+ // runtime that CAN — release() is ownership-guarded), and the new
720
+ // runtime re-arms it in withSession. Without the release, the lock
721
+ // entry would stay forever active-here (same-pid locks never go
722
+ // stale) and the connect could never re-acquire across the cwd
723
+ // change. The probe failing means we are NOT the owner (another pi
724
+ // instance, or Telegram was never connected here): touch nothing.
725
+ const ownedTelegramTransport =
726
+ isTelegramOrigin && probeTelegramTransportOwnership(ctx.cwd);
727
+ let releasedTelegramTransport = false;
728
+ if (ownedTelegramTransport) {
729
+ // Must run while the old runtime is still current, i.e. before
730
+ // ctx.switchSession(). Executes /telegram-disconnect synchronously
731
+ // (pi runs extension commands before any queue/streaming logic).
732
+ releasedTelegramTransport = await releaseTelegramTransportBeforeSwitch(pi, name);
733
+ }
590
734
  const result = await ctx.switchSession(targetSession, {
591
735
  withSession: async (newCtx: any) => {
592
736
  // Safety net in case the restored session has no project entry:
@@ -617,6 +761,13 @@ export default function (pi: ExtensionAPI) {
617
761
  { deliverAs: "followUp" }
618
762
  );
619
763
  }
764
+ 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);
770
+ }
620
771
  },
621
772
  });
622
773
  if (result.cancelled) {
@@ -625,6 +776,23 @@ export default function (pi: ExtensionAPI) {
625
776
  // attempted; an explicit later status/switch overrides it.
626
777
  activeProject = previous;
627
778
  ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
779
+ if (releasedTelegramTransport) {
780
+ // The pre-switch disconnect released the transport for the
781
+ // replacement that is now NOT happening: reconnect from this
782
+ // (still-current) runtime so the cancelled switch leaves the
783
+ // transport in its original state. No lock remains, so the
784
+ // connect re-acquires unconditionally.
785
+ try {
786
+ await pi.sendUserMessage("/telegram-connect", {
787
+ expandPromptTemplates: true,
788
+ });
789
+ } catch (err: any) {
790
+ ctx.ui.notify(
791
+ `Telegram reconnect after cancelled switch failed: ${err?.message ?? err}`,
792
+ "warning"
793
+ );
794
+ }
795
+ }
628
796
  if (isTelegramOrigin) {
629
797
  await ctx.waitForIdle();
630
798
  pi.sendUserMessage(
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name": "pi-project-switcher", "version": "0.7.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.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"}}