pi-project-switcher 0.6.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 +2 -0
- package/index.ts +302 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ 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.
|
|
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.
|
|
17
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.
|
|
18
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.
|
|
19
21
|
- **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
|
package/index.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
22
23
|
import { execSync } from "node:child_process";
|
|
23
24
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
24
25
|
import { homedir } from "node:os";
|
|
@@ -182,6 +183,14 @@ let config: Config | null = null;
|
|
|
182
183
|
*/
|
|
183
184
|
const TELEGRAM_STATUS_RE = /^\[telegram(?:\|[^\]]*)?\]\s*\/project\s*$/;
|
|
184
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Same, but for `/project <name>` (a switch). The command bridge strips
|
|
188
|
+
* the tag and re-dispatches the bare command line, so a Telegram-originated
|
|
189
|
+
* switch would otherwise be indistinguishable from a native invocation.
|
|
190
|
+
* Arguments are captured so the flag matches the command the user sent.
|
|
191
|
+
*/
|
|
192
|
+
const TELEGRAM_SWITCH_RE = /^\[telegram(?:\|[^\]]*)?\]\s*\/project\s+(\S.*)$/;
|
|
193
|
+
|
|
185
194
|
/** How long an armed flag stays valid (ms). Guards against stale flags
|
|
186
195
|
* from dispatches that never reached the command handler. */
|
|
187
196
|
const TELEGRAM_FLAG_TTL_MS = 30_000;
|
|
@@ -189,6 +198,9 @@ const TELEGRAM_FLAG_TTL_MS = 30_000;
|
|
|
189
198
|
/** Armed-at timestamp of a pending Telegram-originated status request, or null. */
|
|
190
199
|
let telegramStatusFlag: number | null = null;
|
|
191
200
|
|
|
201
|
+
/** Armed-at timestamp of a pending Telegram-originated switch request, or null. */
|
|
202
|
+
let telegramSwitchFlag: number | null = null;
|
|
203
|
+
|
|
192
204
|
function armTelegramStatusFlag(): void {
|
|
193
205
|
telegramStatusFlag = Date.now();
|
|
194
206
|
}
|
|
@@ -201,8 +213,21 @@ function consumeTelegramStatusFlag(): boolean {
|
|
|
201
213
|
return Date.now() - armed <= TELEGRAM_FLAG_TTL_MS;
|
|
202
214
|
}
|
|
203
215
|
|
|
216
|
+
function armTelegramSwitchFlag(): void {
|
|
217
|
+
telegramSwitchFlag = Date.now();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Consume the switch flag: returns true (and clears it) when armed and not expired. */
|
|
221
|
+
function consumeTelegramSwitchFlag(): boolean {
|
|
222
|
+
if (telegramSwitchFlag === null) return false;
|
|
223
|
+
const armed = telegramSwitchFlag;
|
|
224
|
+
telegramSwitchFlag = null;
|
|
225
|
+
return Date.now() - armed <= TELEGRAM_FLAG_TTL_MS;
|
|
226
|
+
}
|
|
227
|
+
|
|
204
228
|
function clearTelegramStatusFlag(): void {
|
|
205
229
|
telegramStatusFlag = null;
|
|
230
|
+
telegramSwitchFlag = null;
|
|
206
231
|
}
|
|
207
232
|
|
|
208
233
|
/**
|
|
@@ -258,6 +283,155 @@ function buildTelegramStatusPrompt(projects: string[]): string {
|
|
|
258
283
|
);
|
|
259
284
|
}
|
|
260
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Build the follow-up prompt for a Telegram-originated project switch. The
|
|
288
|
+
* turn's reply is delivered to the Telegram chat as the visible confirmation
|
|
289
|
+
* that the switch succeeded (the local UI notification never reaches the
|
|
290
|
+
* phone). Facts are passed by the extension; the reply shape is prescribed
|
|
291
|
+
* so the model cannot invent outcomes. `sendFn` is passed in so both switch
|
|
292
|
+
* paths reuse this: the restore path must send from the fresh withSession
|
|
293
|
+
* context (never the stale pre-switch pi), the fallback path from the
|
|
294
|
+
* current runtime.
|
|
295
|
+
*/
|
|
296
|
+
function buildTelegramSwitchConfirmationPrompt(opts: {
|
|
297
|
+
project: string;
|
|
298
|
+
path: string;
|
|
299
|
+
branchStr: string;
|
|
300
|
+
sessionLine: string;
|
|
301
|
+
previous: string | null;
|
|
302
|
+
}): string {
|
|
303
|
+
const { project, path, branchStr, sessionLine, previous } = opts;
|
|
304
|
+
|
|
305
|
+
const confirmLine = `✅ Switched to **${project}**${previous ? ` (was: ${previous})` : ""}`;
|
|
306
|
+
const infoLines = [confirmLine, `📁 ${path}${branchStr ? ` (${branchStr})` : ""}`, sessionLine].join("\n");
|
|
307
|
+
|
|
308
|
+
const cells: string[] = ["{📋 Projects|/project}"];
|
|
309
|
+
if (previous && !isUnsafeButtonName(previous) && previous !== project) {
|
|
310
|
+
cells.push(`{↩️ Back to ${previous}|/project ${previous}}`);
|
|
311
|
+
}
|
|
312
|
+
const buttonBlock =
|
|
313
|
+
cells.length > 0
|
|
314
|
+
? "\n\n```telegram_button\n" + cells.join("\n") + "\n```"
|
|
315
|
+
: "";
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
`[project-switcher] The user ran /project ${project} via Telegram and expects a confirmation that the switch succeeded.\n` +
|
|
319
|
+
`Authoritative switch facts (do not re-derive, do not run any command, do not switch projects yourself):\n` +
|
|
320
|
+
`${infoLines}\n\n` +
|
|
321
|
+
`Reply in the chat with exactly the lines above, followed by the button block below, copied verbatim. ` +
|
|
322
|
+
`Add nothing else.${buttonBlock}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Short follow-up for Telegram-originated switch outcomes that changed
|
|
328
|
+
* nothing (already-active or cancelled): guarantees the chat still gets a
|
|
329
|
+
* visible reply instead of silence.
|
|
330
|
+
*/
|
|
331
|
+
function buildTelegramNoChangePrompt(text: string): string {
|
|
332
|
+
return (
|
|
333
|
+
`[project-switcher] The user ran /project via Telegram.\n` +
|
|
334
|
+
`Reply in the chat with exactly this line and nothing else: ${text}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
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
|
+
}
|
|
261
435
|
|
|
262
436
|
function getConfig(): Config {
|
|
263
437
|
if (!config) {
|
|
@@ -367,8 +541,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
367
541
|
pi.on("input", async (event) => {
|
|
368
542
|
if (event.source !== "extension") return;
|
|
369
543
|
const firstLine = event.text.split("\n", 1)[0];
|
|
370
|
-
|
|
544
|
+
const trimmed = firstLine.trim();
|
|
545
|
+
if (TELEGRAM_STATUS_RE.test(trimmed)) {
|
|
371
546
|
armTelegramStatusFlag();
|
|
547
|
+
} else if (TELEGRAM_SWITCH_RE.test(trimmed)) {
|
|
548
|
+
armTelegramSwitchFlag();
|
|
372
549
|
}
|
|
373
550
|
// Never transform or handle: the command bridge owns the re-dispatch.
|
|
374
551
|
});
|
|
@@ -398,6 +575,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
575
|
// "!") only applies to the typed path; dialog users confirm interactively.
|
|
399
576
|
const switchToProject = async (rawName: string, ctx: any, createOptIn: boolean): Promise<void> => {
|
|
400
577
|
const name = rawName;
|
|
578
|
+
const isTelegramOrigin = consumeTelegramSwitchFlag();
|
|
401
579
|
|
|
402
580
|
if (!isValidProject(name)) {
|
|
403
581
|
// Offer to create the folder and switch to it (never silently)
|
|
@@ -438,12 +616,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
438
616
|
`Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
|
|
439
617
|
"warning"
|
|
440
618
|
);
|
|
619
|
+
if (isTelegramOrigin) {
|
|
620
|
+
await ctx.waitForIdle();
|
|
621
|
+
pi.sendUserMessage(
|
|
622
|
+
buildTelegramNoChangePrompt(`⚠️ Unknown project: ${name} — no switch performed.`),
|
|
623
|
+
{ deliverAs: "followUp" }
|
|
624
|
+
);
|
|
625
|
+
}
|
|
441
626
|
return;
|
|
442
627
|
}
|
|
443
628
|
}
|
|
444
629
|
|
|
445
630
|
if (name === activeProject) {
|
|
446
631
|
ctx.ui.notify(`Already on project: ${name}`, "info");
|
|
632
|
+
if (isTelegramOrigin) {
|
|
633
|
+
await ctx.waitForIdle();
|
|
634
|
+
pi.sendUserMessage(buildTelegramNoChangePrompt(`ℹ️ Already on project: **${name}**`), {
|
|
635
|
+
deliverAs: "followUp",
|
|
636
|
+
});
|
|
637
|
+
}
|
|
447
638
|
return;
|
|
448
639
|
}
|
|
449
640
|
|
|
@@ -459,32 +650,110 @@ export default function (pi: ExtensionAPI) {
|
|
|
459
650
|
const targetSession = getMappedSessionFile(name);
|
|
460
651
|
|
|
461
652
|
if (targetSession) {
|
|
462
|
-
//
|
|
463
|
-
|
|
653
|
+
// Pre-write the switch state INTO THE TARGET session file so the
|
|
654
|
+
// fresh runtime's session_start restores the right project. Writing
|
|
655
|
+
// it to the current (old) session instead would leave the target
|
|
656
|
+
// session's own (stale) project entry authoritative.
|
|
657
|
+
// SessionManager.open() advances the target file's leaf to this entry.
|
|
658
|
+
// The same entry must also exist in the old session so a later switch
|
|
659
|
+
// BACK to the previous project still restores it (the old session is
|
|
660
|
+
// remembered under the previous project below).
|
|
661
|
+
try {
|
|
662
|
+
SessionManager.open(targetSession).appendCustomEntry(ENTRY_TYPE, {
|
|
663
|
+
project: name,
|
|
664
|
+
switchedAt: new Date().toISOString(),
|
|
665
|
+
});
|
|
666
|
+
} catch (err: any) {
|
|
667
|
+
ctx.ui.notify(
|
|
668
|
+
`Could not persist switch state to target session: ${err?.message ?? err}`,
|
|
669
|
+
"warning"
|
|
670
|
+
);
|
|
671
|
+
}
|
|
464
672
|
|
|
465
|
-
|
|
673
|
+
// Persist the switch in the OLD session too (for switching back later).
|
|
674
|
+
// Still valid: the old runtime has not been invalidated yet.
|
|
675
|
+
try {
|
|
676
|
+
pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
|
|
677
|
+
} catch {
|
|
678
|
+
// Non-fatal: the target-session entry above is the authoritative one.
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Everything after ctx.switchSession() must run in withSession: the
|
|
682
|
+
// captured `pi` and command `ctx` are stale once the session is
|
|
683
|
+
// replaced, and using them throws (previously crashed the command
|
|
684
|
+
// with "stale ctx" errors and killed the whole flow).
|
|
685
|
+
const branch = getGitBranch(projectPath(name));
|
|
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);
|
|
696
|
+
const result = await ctx.switchSession(targetSession, {
|
|
697
|
+
withSession: async (newCtx: any) => {
|
|
698
|
+
// Safety net in case the restored session has no project entry:
|
|
699
|
+
// session_start has already run for the new runtime; if it
|
|
700
|
+
// restored a different project from a stale entry, the pre-written
|
|
701
|
+
// entry above is the LAST project-switcher-state entry in the file
|
|
702
|
+
// and therefore authoritative — set the in-memory state explicitly.
|
|
703
|
+
activeProject = name;
|
|
704
|
+
newCtx.ui.notify(
|
|
705
|
+
`Switched to ${name} — session restored: ${basename(targetSession)}\n` +
|
|
706
|
+
`Workdir: ${projectPath(name)}${branchStr ? ` ${branchStr}` : ""}`,
|
|
707
|
+
"info"
|
|
708
|
+
);
|
|
709
|
+
if (isTelegramOrigin) {
|
|
710
|
+
// Confirmation turn from the FRESH runtime: the reply reaches
|
|
711
|
+
// the Telegram chat (the local notify above never does). Only
|
|
712
|
+
// the new context is touched — the pre-switch pi/ctx are stale.
|
|
713
|
+
// The turn also settles pi-telegram's dispatch queue.
|
|
714
|
+
await newCtx.waitForIdle();
|
|
715
|
+
await newCtx.sendUserMessage(
|
|
716
|
+
buildTelegramSwitchConfirmationPrompt({
|
|
717
|
+
project: name,
|
|
718
|
+
path: projectPath(name),
|
|
719
|
+
branchStr,
|
|
720
|
+
sessionLine: `🗂 Session restored: ${basename(targetSession)}`,
|
|
721
|
+
previous,
|
|
722
|
+
}),
|
|
723
|
+
{ deliverAs: "followUp" }
|
|
724
|
+
);
|
|
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
|
+
}
|
|
733
|
+
},
|
|
734
|
+
});
|
|
466
735
|
if (result.cancelled) {
|
|
467
|
-
// User cancelled; roll back in-memory state
|
|
736
|
+
// User cancelled; roll back in-memory state. The pre-written target
|
|
737
|
+
// entry is harmless: it only records that a switch to `name` was
|
|
738
|
+
// attempted; an explicit later status/switch overrides it.
|
|
468
739
|
activeProject = previous;
|
|
469
740
|
ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
|
|
741
|
+
if (isTelegramOrigin) {
|
|
742
|
+
await ctx.waitForIdle();
|
|
743
|
+
pi.sendUserMessage(
|
|
744
|
+
buildTelegramNoChangePrompt(
|
|
745
|
+
`⚠️ Switch to ${name} cancelled — staying on ${previous ?? "no project"}.`
|
|
746
|
+
),
|
|
747
|
+
{ deliverAs: "followUp" }
|
|
748
|
+
);
|
|
749
|
+
}
|
|
470
750
|
return;
|
|
471
751
|
}
|
|
472
752
|
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
activeProject = name;
|
|
477
|
-
pi.setSessionName(name);
|
|
753
|
+
// Remember the mapping AFTER a successful switch (withSession has
|
|
754
|
+
// already run). rememberSessionFile only touches the JSON map on
|
|
755
|
+
// disk, so calling it here is safe.
|
|
478
756
|
rememberSessionFile(name, targetSession);
|
|
479
|
-
|
|
480
|
-
const path = projectPath(name);
|
|
481
|
-
const branch = getGitBranch(path);
|
|
482
|
-
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
483
|
-
ctx.ui.notify(
|
|
484
|
-
`Switched to ${name} — session restored: ${basename(targetSession)}\n` +
|
|
485
|
-
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
|
|
486
|
-
"info"
|
|
487
|
-
);
|
|
488
757
|
return;
|
|
489
758
|
}
|
|
490
759
|
|
|
@@ -515,12 +784,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
515
784
|
"info"
|
|
516
785
|
);
|
|
517
786
|
|
|
518
|
-
// Announce to the agent so it operates in the new context
|
|
787
|
+
// Announce to the agent so it operates in the new context — or, for a
|
|
788
|
+
// Telegram-originated switch, send the chat-visible confirmation turn
|
|
789
|
+
// (it doubles as the agent context announcement).
|
|
519
790
|
await ctx.waitForIdle();
|
|
520
791
|
pi.sendUserMessage(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
792
|
+
isTelegramOrigin
|
|
793
|
+
? buildTelegramSwitchConfirmationPrompt({
|
|
794
|
+
project: name,
|
|
795
|
+
path,
|
|
796
|
+
branchStr,
|
|
797
|
+
sessionLine: `🗂 First session in this project (${sessionLine})`,
|
|
798
|
+
previous,
|
|
799
|
+
})
|
|
800
|
+
: `[Project switched to **${name}**]\n` +
|
|
801
|
+
`Working directory: \`${path}\`${branchStr}\n` +
|
|
802
|
+
`Please keep all file operations within this project from now on.`,
|
|
524
803
|
{ deliverAs: "followUp" }
|
|
525
804
|
);
|
|
526
805
|
};
|
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"}}
|