pi-project-switcher 0.6.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/index.ts +189 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,6 +14,7 @@ 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.
17
18
  - **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
19
  - **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
20
  - **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,58 @@ 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
+
261
338
 
262
339
  function getConfig(): Config {
263
340
  if (!config) {
@@ -367,8 +444,11 @@ export default function (pi: ExtensionAPI) {
367
444
  pi.on("input", async (event) => {
368
445
  if (event.source !== "extension") return;
369
446
  const firstLine = event.text.split("\n", 1)[0];
370
- if (TELEGRAM_STATUS_RE.test(firstLine.trim())) {
447
+ const trimmed = firstLine.trim();
448
+ if (TELEGRAM_STATUS_RE.test(trimmed)) {
371
449
  armTelegramStatusFlag();
450
+ } else if (TELEGRAM_SWITCH_RE.test(trimmed)) {
451
+ armTelegramSwitchFlag();
372
452
  }
373
453
  // Never transform or handle: the command bridge owns the re-dispatch.
374
454
  });
@@ -398,6 +478,7 @@ export default function (pi: ExtensionAPI) {
398
478
  // "!") only applies to the typed path; dialog users confirm interactively.
399
479
  const switchToProject = async (rawName: string, ctx: any, createOptIn: boolean): Promise<void> => {
400
480
  const name = rawName;
481
+ const isTelegramOrigin = consumeTelegramSwitchFlag();
401
482
 
402
483
  if (!isValidProject(name)) {
403
484
  // Offer to create the folder and switch to it (never silently)
@@ -438,12 +519,25 @@ export default function (pi: ExtensionAPI) {
438
519
  `Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
439
520
  "warning"
440
521
  );
522
+ if (isTelegramOrigin) {
523
+ await ctx.waitForIdle();
524
+ pi.sendUserMessage(
525
+ buildTelegramNoChangePrompt(`⚠️ Unknown project: ${name} — no switch performed.`),
526
+ { deliverAs: "followUp" }
527
+ );
528
+ }
441
529
  return;
442
530
  }
443
531
  }
444
532
 
445
533
  if (name === activeProject) {
446
534
  ctx.ui.notify(`Already on project: ${name}`, "info");
535
+ if (isTelegramOrigin) {
536
+ await ctx.waitForIdle();
537
+ pi.sendUserMessage(buildTelegramNoChangePrompt(`ℹ️ Already on project: **${name}**`), {
538
+ deliverAs: "followUp",
539
+ });
540
+ }
447
541
  return;
448
542
  }
449
543
 
@@ -459,32 +553,94 @@ export default function (pi: ExtensionAPI) {
459
553
  const targetSession = getMappedSessionFile(name);
460
554
 
461
555
  if (targetSession) {
462
- // Persist the switch in the OLD session before replacing it
463
- pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
556
+ // Pre-write the switch state INTO THE TARGET session file so the
557
+ // fresh runtime's session_start restores the right project. Writing
558
+ // it to the current (old) session instead would leave the target
559
+ // session's own (stale) project entry authoritative.
560
+ // SessionManager.open() advances the target file's leaf to this entry.
561
+ // The same entry must also exist in the old session so a later switch
562
+ // BACK to the previous project still restores it (the old session is
563
+ // remembered under the previous project below).
564
+ try {
565
+ SessionManager.open(targetSession).appendCustomEntry(ENTRY_TYPE, {
566
+ project: name,
567
+ switchedAt: new Date().toISOString(),
568
+ });
569
+ } catch (err: any) {
570
+ ctx.ui.notify(
571
+ `Could not persist switch state to target session: ${err?.message ?? err}`,
572
+ "warning"
573
+ );
574
+ }
464
575
 
465
- const result = await ctx.switchSession(targetSession);
576
+ // Persist the switch in the OLD session too (for switching back later).
577
+ // Still valid: the old runtime has not been invalidated yet.
578
+ try {
579
+ pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
580
+ } catch {
581
+ // Non-fatal: the target-session entry above is the authoritative one.
582
+ }
583
+
584
+ // Everything after ctx.switchSession() must run in withSession: the
585
+ // captured `pi` and command `ctx` are stale once the session is
586
+ // replaced, and using them throws (previously crashed the command
587
+ // with "stale ctx" errors and killed the whole flow).
588
+ const branch = getGitBranch(projectPath(name));
589
+ const branchStr = branch ? ` on branch \`${branch}\`` : "";
590
+ const result = await ctx.switchSession(targetSession, {
591
+ withSession: async (newCtx: any) => {
592
+ // Safety net in case the restored session has no project entry:
593
+ // session_start has already run for the new runtime; if it
594
+ // restored a different project from a stale entry, the pre-written
595
+ // entry above is the LAST project-switcher-state entry in the file
596
+ // and therefore authoritative — set the in-memory state explicitly.
597
+ activeProject = name;
598
+ newCtx.ui.notify(
599
+ `Switched to ${name} — session restored: ${basename(targetSession)}\n` +
600
+ `Workdir: ${projectPath(name)}${branchStr ? ` ${branchStr}` : ""}`,
601
+ "info"
602
+ );
603
+ if (isTelegramOrigin) {
604
+ // Confirmation turn from the FRESH runtime: the reply reaches
605
+ // the Telegram chat (the local notify above never does). Only
606
+ // the new context is touched — the pre-switch pi/ctx are stale.
607
+ // The turn also settles pi-telegram's dispatch queue.
608
+ await newCtx.waitForIdle();
609
+ await newCtx.sendUserMessage(
610
+ buildTelegramSwitchConfirmationPrompt({
611
+ project: name,
612
+ path: projectPath(name),
613
+ branchStr,
614
+ sessionLine: `🗂 Session restored: ${basename(targetSession)}`,
615
+ previous,
616
+ }),
617
+ { deliverAs: "followUp" }
618
+ );
619
+ }
620
+ },
621
+ });
466
622
  if (result.cancelled) {
467
- // User cancelled; roll back in-memory state
623
+ // User cancelled; roll back in-memory state. The pre-written target
624
+ // entry is harmless: it only records that a switch to `name` was
625
+ // attempted; an explicit later status/switch overrides it.
468
626
  activeProject = previous;
469
627
  ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
628
+ if (isTelegramOrigin) {
629
+ await ctx.waitForIdle();
630
+ pi.sendUserMessage(
631
+ buildTelegramNoChangePrompt(
632
+ `⚠️ Switch to ${name} cancelled — staying on ${previous ?? "no project"}.`
633
+ ),
634
+ { deliverAs: "followUp" }
635
+ );
636
+ }
470
637
  return;
471
638
  }
472
639
 
473
- // switchSession fires a new session_start, which restores state from
474
- // the target session's entries (or auto-detects). Set it explicitly as
475
- // a safety net in case the session has no project entry yet.
476
- activeProject = name;
477
- pi.setSessionName(name);
640
+ // Remember the mapping AFTER a successful switch (withSession has
641
+ // already run). rememberSessionFile only touches the JSON map on
642
+ // disk, so calling it here is safe.
478
643
  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
644
  return;
489
645
  }
490
646
 
@@ -515,12 +671,22 @@ export default function (pi: ExtensionAPI) {
515
671
  "info"
516
672
  );
517
673
 
518
- // Announce to the agent so it operates in the new context
674
+ // Announce to the agent so it operates in the new context — or, for a
675
+ // Telegram-originated switch, send the chat-visible confirmation turn
676
+ // (it doubles as the agent context announcement).
519
677
  await ctx.waitForIdle();
520
678
  pi.sendUserMessage(
521
- `[Project switched to **${name}**]\n` +
522
- `Working directory: \`${path}\`${branchStr}\n` +
523
- `Please keep all file operations within this project from now on.`,
679
+ isTelegramOrigin
680
+ ? buildTelegramSwitchConfirmationPrompt({
681
+ project: name,
682
+ path,
683
+ branchStr,
684
+ sessionLine: `🗂 First session in this project (${sessionLine})`,
685
+ previous,
686
+ })
687
+ : `[Project switched to **${name}**]\n` +
688
+ `Working directory: \`${path}\`${branchStr}\n` +
689
+ `Please keep all file operations within this project from now on.`,
524
690
  { deliverAs: "followUp" }
525
691
  );
526
692
  };
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name": "pi-project-switcher", "version": "0.6.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.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"}}