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.
- package/README.md +1 -0
- package/index.ts +168 -0
- 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.
|
|
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"}}
|