pi-project-switcher 0.8.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 -1
  2. package/index.ts +73 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +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 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.
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
@@ -356,13 +356,39 @@ function telegramOwnersPath(): string {
356
356
  const TELEGRAM_OWNERSHIP_FRESH_MS = 10_000;
357
357
 
358
358
  /**
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.
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.
364
363
  */
365
- const TELEGRAM_REARM_DELAY_MS = 9_000;
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
+ }
366
392
 
367
393
  /**
368
394
  * Read-only probe: is the session we are about to leave the live owner of
@@ -412,8 +438,10 @@ function scheduleTelegramRearm(newCtx: any, source: string): void {
412
438
  pendingRearmTimer = null;
413
439
  try {
414
440
  // 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.
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).
417
445
  await newCtx.sendUserMessage("/telegram-connect", {
418
446
  expandPromptTemplates: true,
419
447
  });
@@ -686,13 +714,23 @@ export default function (pi: ExtensionAPI) {
686
714
  const branchStr = branch ? ` on branch \`${branch}\`` : "";
687
715
  // Ownership probe BEFORE the switch (the old session's cwd is only
688
716
  // 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.
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.
694
725
  const ownedTelegramTransport =
695
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
+ }
696
734
  const result = await ctx.switchSession(targetSession, {
697
735
  withSession: async (newCtx: any) => {
698
736
  // Safety net in case the restored session has no project entry:
@@ -723,11 +761,11 @@ export default function (pi: ExtensionAPI) {
723
761
  { deliverAs: "followUp" }
724
762
  );
725
763
  }
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.
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).
731
769
  scheduleTelegramRearm(newCtx, name);
732
770
  }
733
771
  },
@@ -738,6 +776,23 @@ export default function (pi: ExtensionAPI) {
738
776
  // attempted; an explicit later status/switch overrides it.
739
777
  activeProject = previous;
740
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
+ }
741
796
  if (isTelegramOrigin) {
742
797
  await ctx.waitForIdle();
743
798
  pi.sendUserMessage(
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.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"}}