pi-project-switcher 0.7.0 → 0.8.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.
- package/README.md +1 -0
- package/index.ts +113 -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 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
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,103 @@ 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 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.
|
|
364
|
+
*/
|
|
365
|
+
const TELEGRAM_REARM_DELAY_MS = 9_000;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Read-only probe: is the session we are about to leave the live owner of
|
|
369
|
+
* the connected Telegram transport? True iff pi-telegram's lock file has
|
|
370
|
+
* an entry for the current process whose heartbeat is fresh and whose cwd
|
|
371
|
+
* matches the old session's cwd (or is absent). This is the guard that
|
|
372
|
+
* prevents stealing the bot from a different pi instance or re-arming in
|
|
373
|
+
* a session that never used Telegram.
|
|
374
|
+
*/
|
|
375
|
+
function probeTelegramTransportOwnership(oldCwd: string): boolean {
|
|
376
|
+
try {
|
|
377
|
+
const raw = JSON.parse(readFileSync(telegramOwnersPath(), "utf8"));
|
|
378
|
+
if (!raw || typeof raw !== "object") return false;
|
|
379
|
+
// Any profile entry counts (the default profile uses the key "default").
|
|
380
|
+
for (const entry of Object.values(raw) as any[]) {
|
|
381
|
+
if (
|
|
382
|
+
entry &&
|
|
383
|
+
typeof entry === "object" &&
|
|
384
|
+
entry.pid === process.pid &&
|
|
385
|
+
typeof entry.heartbeatMs === "number" &&
|
|
386
|
+
Date.now() - entry.heartbeatMs <= TELEGRAM_OWNERSHIP_FRESH_MS &&
|
|
387
|
+
(entry.cwd === undefined || entry.cwd === oldCwd)
|
|
388
|
+
) {
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
} catch {
|
|
394
|
+
// Missing or malformed lock file: transport not owned here.
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
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.
|
|
403
|
+
*/
|
|
404
|
+
let pendingRearmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
405
|
+
|
|
406
|
+
function scheduleTelegramRearm(newCtx: any, source: string): void {
|
|
407
|
+
if (pendingRearmTimer) {
|
|
408
|
+
clearTimeout(pendingRearmTimer);
|
|
409
|
+
pendingRearmTimer = null;
|
|
410
|
+
}
|
|
411
|
+
const timer = setTimeout(async () => {
|
|
412
|
+
pendingRearmTimer = null;
|
|
413
|
+
try {
|
|
414
|
+
// 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.
|
|
417
|
+
await newCtx.sendUserMessage("/telegram-connect", {
|
|
418
|
+
expandPromptTemplates: true,
|
|
419
|
+
});
|
|
420
|
+
} catch (err: any) {
|
|
421
|
+
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.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}, TELEGRAM_REARM_DELAY_MS);
|
|
432
|
+
timer.unref?.();
|
|
433
|
+
pendingRearmTimer = timer;
|
|
434
|
+
}
|
|
338
435
|
|
|
339
436
|
function getConfig(): Config {
|
|
340
437
|
if (!config) {
|
|
@@ -587,6 +684,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
587
684
|
// with "stale ctx" errors and killed the whole flow).
|
|
588
685
|
const branch = getGitBranch(projectPath(name));
|
|
589
686
|
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
687
|
+
// Ownership probe BEFORE the switch (the old session's cwd is only
|
|
688
|
+
// 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.
|
|
694
|
+
const ownedTelegramTransport =
|
|
695
|
+
isTelegramOrigin && probeTelegramTransportOwnership(ctx.cwd);
|
|
590
696
|
const result = await ctx.switchSession(targetSession, {
|
|
591
697
|
withSession: async (newCtx: any) => {
|
|
592
698
|
// Safety net in case the restored session has no project entry:
|
|
@@ -617,6 +723,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
617
723
|
{ deliverAs: "followUp" }
|
|
618
724
|
);
|
|
619
725
|
}
|
|
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);
|
|
732
|
+
}
|
|
620
733
|
},
|
|
621
734
|
});
|
|
622
735
|
if (result.cancelled) {
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name": "pi-project-switcher", "version": "0.
|
|
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"}}
|