pi-project-switcher 0.5.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 +7 -2
  2. package/index.ts +455 -127
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,12 +4,17 @@ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch be
4
4
 
5
5
  ## What it does
6
6
 
7
- - **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one
7
+ - **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one. The output adapts to the surface:
8
+ - **native TUI**: a selection dialog; picking a project switches to it (dismissing shows the plain list)
9
+ - **Telegram bridge**: the reply in the chat shows one tappable button per project; clicking a button is exactly like typing `/project <name>`
10
+ - **other surfaces** (rpc/json/print): plain text list
11
+ - Note: for the Telegram buttons to work, this extension must be loaded **before** `pi-telegram-command-bridge` (package order in `~/.pi/agent/settings.json`) — the switcher needs to see the raw `[telegram] /project` dispatch before the bridge re-dispatches it.
8
12
  - **`/project <name>`** — switch the active project:
9
13
  - restores the project's last session if one is stored (see below)
10
14
  - persists across reloads (session entry)
11
15
  - sets the session display name
12
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.
13
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.
14
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.
15
20
  - **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
@@ -51,7 +56,7 @@ Single-file TypeScript extension (`index.ts`), loaded directly by pi via jiti
51
56
 
52
57
  ```bash
53
58
  npm install
54
- npm test # vitest (17 tests)
59
+ npm test # vitest (45 tests)
55
60
  npm run typecheck
56
61
 
57
62
  # Run once without installing
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";
@@ -173,6 +174,168 @@ function rememberSessionFile(project: string, absSessionFile: string): void {
173
174
  let activeProject: string | null = null;
174
175
  let config: Config | null = null;
175
176
 
177
+ // ── Telegram origin detection (input event + flag) ───────────────────────────
178
+
179
+ /**
180
+ * Matches a first line dispatched from the pi-telegram bridge: a
181
+ * `[telegram]` tag (optionally with attributes like `[telegram|thread:x]`)
182
+ * followed by a bare `/project` with no arguments.
183
+ */
184
+ const TELEGRAM_STATUS_RE = /^\[telegram(?:\|[^\]]*)?\]\s*\/project\s*$/;
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
+
194
+ /** How long an armed flag stays valid (ms). Guards against stale flags
195
+ * from dispatches that never reached the command handler. */
196
+ const TELEGRAM_FLAG_TTL_MS = 30_000;
197
+
198
+ /** Armed-at timestamp of a pending Telegram-originated status request, or null. */
199
+ let telegramStatusFlag: number | null = null;
200
+
201
+ /** Armed-at timestamp of a pending Telegram-originated switch request, or null. */
202
+ let telegramSwitchFlag: number | null = null;
203
+
204
+ function armTelegramStatusFlag(): void {
205
+ telegramStatusFlag = Date.now();
206
+ }
207
+
208
+ /** Consume the flag: returns true (and clears it) when armed and not expired. */
209
+ function consumeTelegramStatusFlag(): boolean {
210
+ if (telegramStatusFlag === null) return false;
211
+ const armed = telegramStatusFlag;
212
+ telegramStatusFlag = null;
213
+ return Date.now() - armed <= TELEGRAM_FLAG_TTL_MS;
214
+ }
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
+
228
+ function clearTelegramStatusFlag(): void {
229
+ telegramStatusFlag = null;
230
+ telegramSwitchFlag = null;
231
+ }
232
+
233
+ /**
234
+ * A project name that could alter button markup or the button prompt is
235
+ * rendered as plain list text only (never gets a button cell).
236
+ */
237
+ function isUnsafeButtonName(name: string): boolean {
238
+ return /[{}|`\\\n]/.test(name);
239
+ }
240
+
241
+ /**
242
+ * Build the follow-up prompt for a Telegram-originated status request.
243
+ * Embeds the authoritative project list and a pre-rendered telegram_button
244
+ * block the agent copies verbatim into its reply.
245
+ */
246
+ function buildTelegramStatusPrompt(projects: string[]): string {
247
+ const ordered = [...projects];
248
+ if (activeProject) {
249
+ const idx = ordered.indexOf(activeProject);
250
+ if (idx > 0) {
251
+ ordered.splice(idx, 1);
252
+ ordered.unshift(activeProject);
253
+ } else if (idx === -1) {
254
+ ordered.unshift(activeProject);
255
+ }
256
+ }
257
+
258
+ const listLines = ordered
259
+ .map((p) => ` ${p}${p === activeProject ? " ◀ active" : ""}`)
260
+ .join("\n");
261
+
262
+ const buttonCells = ordered
263
+ .filter((p) => !isUnsafeButtonName(p))
264
+ .map((p) =>
265
+ p === activeProject
266
+ ? `{✅ ${p} (active)|/project ${p}}`
267
+ : `{📁 ${p}|/project ${p}}`,
268
+ )
269
+ .join("\n");
270
+ const buttonBlock =
271
+ buttonCells.length > 0
272
+ ? "\n\n```telegram_button\n" + buttonCells + "\n```"
273
+ : "";
274
+
275
+ const current = activeProject ? `Active: ${activeProject}` : "No project active";
276
+
277
+ return (
278
+ `[project-switcher] The user ran /project via Telegram and expects the project list in the chat.\n` +
279
+ `Authoritative list (do not re-derive, do not add or remove entries):\n` +
280
+ `${current}\n\n${listLines}\n\n` +
281
+ `Reply in the chat with exactly this list (keep the active marker) followed by the button block below, ` +
282
+ `copied verbatim. Do not run any command, do not switch projects yourself, and add nothing else.${buttonBlock}`
283
+ );
284
+ }
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
+
176
339
  function getConfig(): Config {
177
340
  if (!config) {
178
341
  config = loadConfig();
@@ -209,9 +372,30 @@ function isSafeProjectName(name: string): boolean {
209
372
  return true;
210
373
  }
211
374
 
375
+ /**
376
+ * Plain text project list via the local UI notification channel —
377
+ * the unchanged default output for non-Telegram surfaces.
378
+ */
379
+ function notifyPlainProjectList(ctx: any, projects: string[]): void {
380
+ const current = activeProject ? `Active: ${activeProject}` : "No project active";
381
+ const lines = projects.map((p) => {
382
+ const branch = getGitBranch(projectPath(p));
383
+ const branchStr = branch ? ` [${branch}]` : "";
384
+ const marker = p === activeProject ? " ◀ active" : "";
385
+ return ` ${p}${branchStr}${marker}`;
386
+ });
387
+ ctx.ui.notify(
388
+ `${current}\n\nProjects under ${getConfig().baseDir}:\n${lines.join("\n")}`,
389
+ "info"
390
+ );
391
+ }
392
+
212
393
  export default function (pi: ExtensionAPI) {
213
394
  // ── Restore state on session start ──────────────────────────────────────
214
395
  pi.on("session_start", async (_event, ctx) => {
396
+ // Session switches reset any pending Telegram status flag
397
+ clearTelegramStatusFlag();
398
+
215
399
  for (const entry of ctx.sessionManager.getEntries()) {
216
400
  if (
217
401
  entry.type === "custom" &&
@@ -244,6 +428,31 @@ export default function (pi: ExtensionAPI) {
244
428
  }
245
429
  });
246
430
 
431
+ // ── Telegram origin detection (input event) ────────────────────────────────
432
+ //
433
+ // Prompts dispatched from the pi-telegram queue arrive with source
434
+ // "extension" and a `[telegram]`-tagged first line, BEFORE the command
435
+ // bridge re-dispatches the bare command line. Recognizing the raw
436
+ // dispatch here is the only reliable origin signal; the re-dispatched
437
+ // text is indistinguishable from a native TUI invocation.
438
+ //
439
+ // Requires this extension to be listed BEFORE the command bridge in the
440
+ // package order (the bridge's "handled" result would short-circuit the
441
+ // input chain before this handler runs otherwise). Wrong order degrades
442
+ // gracefully: the flag is never armed and the status falls back to the
443
+ // plain list.
444
+ pi.on("input", async (event) => {
445
+ if (event.source !== "extension") return;
446
+ const firstLine = event.text.split("\n", 1)[0];
447
+ const trimmed = firstLine.trim();
448
+ if (TELEGRAM_STATUS_RE.test(trimmed)) {
449
+ armTelegramStatusFlag();
450
+ } else if (TELEGRAM_SWITCH_RE.test(trimmed)) {
451
+ armTelegramSwitchFlag();
452
+ }
453
+ // Never transform or handle: the command bridge owns the re-dispatch.
454
+ });
455
+
247
456
  // ── Inject project context into every agent turn ─────────────────────────
248
457
  pi.on("before_agent_start", async (event, _ctx) => {
249
458
  if (!activeProject || !isValidProject(activeProject)) {
@@ -262,6 +471,226 @@ export default function (pi: ExtensionAPI) {
262
471
  };
263
472
  });
264
473
 
474
+ // ── Shared switch flow ──────────────────────────────────────────────────
475
+ //
476
+ // Single implementation of /project <name> semantics, used by the typed
477
+ // argument path and the TUI selection dialog. `createOptIn` (trailing
478
+ // "!") only applies to the typed path; dialog users confirm interactively.
479
+ const switchToProject = async (rawName: string, ctx: any, createOptIn: boolean): Promise<void> => {
480
+ const name = rawName;
481
+ const isTelegramOrigin = consumeTelegramSwitchFlag();
482
+
483
+ if (!isValidProject(name)) {
484
+ // Offer to create the folder and switch to it (never silently)
485
+ if (isSafeProjectName(name)) {
486
+ let create = false;
487
+ if (createOptIn) {
488
+ create = true;
489
+ } else if (ctx.hasUI) {
490
+ try {
491
+ create = await ctx.ui.confirm(
492
+ "Create project?",
493
+ `Project "${name}" does not exist. Create ${join(getConfig().baseDir, name)} and switch to it?`
494
+ );
495
+ } catch {
496
+ create = false;
497
+ }
498
+ }
499
+
500
+ if (create) {
501
+ const newPath = projectPath(name);
502
+ try {
503
+ mkdirSync(newPath, { recursive: false });
504
+ ctx.ui.notify(`Created project folder: ${newPath}`, "info");
505
+ } catch (err: any) {
506
+ ctx.ui.notify(
507
+ `Failed to create project folder ${newPath}: ${err?.message ?? err}`,
508
+ "error"
509
+ );
510
+ return;
511
+ }
512
+ // fall through: the folder now exists and the switch proceeds below
513
+ }
514
+ }
515
+
516
+ if (!isValidProject(name)) {
517
+ const available = discoverProjects(getConfig().baseDir).join(", ");
518
+ ctx.ui.notify(
519
+ `Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
520
+ "warning"
521
+ );
522
+ if (isTelegramOrigin) {
523
+ await ctx.waitForIdle();
524
+ pi.sendUserMessage(
525
+ buildTelegramNoChangePrompt(`⚠️ Unknown project: ${name} — no switch performed.`),
526
+ { deliverAs: "followUp" }
527
+ );
528
+ }
529
+ return;
530
+ }
531
+ }
532
+
533
+ if (name === activeProject) {
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
+ }
541
+ return;
542
+ }
543
+
544
+ const previous = activeProject;
545
+
546
+ // Remember the current session under the PREVIOUS project before switching
547
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
548
+ if (previous && currentSessionFile) {
549
+ rememberSessionFile(previous, currentSessionFile);
550
+ }
551
+
552
+ // Try to restore the target project's last session
553
+ const targetSession = getMappedSessionFile(name);
554
+
555
+ if (targetSession) {
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
+ }
575
+
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
+ });
622
+ if (result.cancelled) {
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.
626
+ activeProject = previous;
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
+ }
637
+ return;
638
+ }
639
+
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.
643
+ rememberSessionFile(name, targetSession);
644
+ return;
645
+ }
646
+
647
+ // ── Fallback: no stored session (or file gone) -> same-session switch ─
648
+ activeProject = name;
649
+
650
+ // Persist to session
651
+ pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
652
+
653
+ // Update session name
654
+ pi.setSessionName(name);
655
+
656
+ const path = projectPath(name);
657
+ const branch = getGitBranch(path);
658
+ const branchStr = branch ? ` on branch \`${branch}\`` : "";
659
+ const fromStr = previous ? ` (was: ${previous})` : "";
660
+
661
+ // Current session identity — still valid on this path (no session replacement)
662
+ const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
663
+ const sessionLine = currentFile
664
+ ? `Continuing session: ${basename(currentFile)}`
665
+ : "Continuing current session";
666
+
667
+ ctx.ui.notify(
668
+ `Switched to ${name}${fromStr} — first session in this project\n` +
669
+ `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}\n` +
670
+ sessionLine,
671
+ "info"
672
+ );
673
+
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).
677
+ await ctx.waitForIdle();
678
+ pi.sendUserMessage(
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.`,
690
+ { deliverAs: "followUp" }
691
+ );
692
+ };
693
+
265
694
  // ── /project command ─────────────────────────────────────────────────────
266
695
  pi.registerCommand("project", {
267
696
  description: "Show or switch active project (/project [name])",
@@ -283,7 +712,7 @@ export default function (pi: ExtensionAPI) {
283
712
  name = name.slice(0, -1).trim();
284
713
  }
285
714
 
286
- // ── No arg: show status ──────────────────────────────────────────────
715
+ // ── No arg: surface-adaptive status ──────────────────────────────────
287
716
  if (!name) {
288
717
  const projects = discoverProjects(getConfig().baseDir);
289
718
  if (projects.length === 0) {
@@ -293,142 +722,41 @@ export default function (pi: ExtensionAPI) {
293
722
  );
294
723
  return;
295
724
  }
296
- const current = activeProject ? `Active: ${activeProject}` : "No project active";
297
- const lines = projects.map((p) => {
298
- const branch = getGitBranch(projectPath(p));
299
- const branchStr = branch ? ` [${branch}]` : "";
300
- const marker = p === activeProject ? " ◀ active" : "";
301
- return ` ${p}${branchStr}${marker}`;
302
- });
303
- ctx.ui.notify(`${current}\n\nProjects under ${getConfig().baseDir}:\n${lines.join("\n")}`, "info");
304
- return;
305
- }
306
-
307
- // ── Switch project ────────────────────────────────────────────────────
308
- if (!isValidProject(name)) {
309
- // Offer to create the folder and switch to it (never silently)
310
- if (isSafeProjectName(name)) {
311
- let create = false;
312
- if (createOptIn) {
313
- create = true;
314
- } else if (ctx.hasUI) {
315
- try {
316
- create = await ctx.ui.confirm(
317
- "Create project?",
318
- `Project "${name}" does not exist. Create ${join(getConfig().baseDir, name)} and switch to it?`
319
- );
320
- } catch {
321
- create = false;
322
- }
323
- }
324
-
325
- if (create) {
326
- const newPath = projectPath(name);
327
- try {
328
- mkdirSync(newPath, { recursive: false });
329
- ctx.ui.notify(`Created project folder: ${newPath}`, "info");
330
- } catch (err: any) {
331
- ctx.ui.notify(
332
- `Failed to create project folder ${newPath}: ${err?.message ?? err}`,
333
- "error"
334
- );
335
- return;
336
- }
337
- // fall through: the folder now exists and the switch proceeds below
338
- }
339
- }
340
725
 
341
- if (!isValidProject(name)) {
342
- const available = discoverProjects(getConfig().baseDir).join(", ");
343
- ctx.ui.notify(
344
- `Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
345
- "warning"
346
- );
726
+ const isTelegramOrigin = consumeTelegramStatusFlag();
727
+
728
+ if (isTelegramOrigin) {
729
+ // Plain list for the local surface (parity with today) …
730
+ notifyPlainProjectList(ctx, projects);
731
+ // … then a follow-up turn whose reply reaches the Telegram chat,
732
+ // with one button per project. The turn also settles the command
733
+ // bridge's pending dispatch (its agent_start hook clears the
734
+ // pending settle entry first).
735
+ await ctx.waitForIdle();
736
+ pi.sendUserMessage(buildTelegramStatusPrompt(projects), {
737
+ deliverAs: "followUp",
738
+ });
347
739
  return;
348
740
  }
349
- }
350
-
351
- if (name === activeProject) {
352
- ctx.ui.notify(`Already on project: ${name}`, "info");
353
- return;
354
- }
355
-
356
- const previous = activeProject;
357
741
 
358
- // Remember the current session under the PREVIOUS project before switching
359
- const currentSessionFile = ctx.sessionManager.getSessionFile();
360
- if (previous && currentSessionFile) {
361
- rememberSessionFile(previous, currentSessionFile);
362
- }
363
-
364
- // Try to restore the target project's last session
365
- const targetSession = getMappedSessionFile(name);
366
-
367
- if (targetSession) {
368
- // Persist the switch in the OLD session before replacing it
369
- pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
370
-
371
- const result = await ctx.switchSession(targetSession);
372
- if (result.cancelled) {
373
- // User cancelled; roll back in-memory state
374
- activeProject = previous;
375
- ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
742
+ if (ctx.mode === "tui") {
743
+ // Selection dialog; dismissing falls back to the plain list.
744
+ const choice = await ctx.ui.select("Switch project", projects);
745
+ if (choice !== undefined) {
746
+ await switchToProject(choice, ctx, false);
747
+ return;
748
+ }
749
+ notifyPlainProjectList(ctx, projects);
376
750
  return;
377
751
  }
378
752
 
379
- // switchSession fires a new session_start, which restores state from
380
- // the target session's entries (or auto-detects). Set it explicitly as
381
- // a safety net in case the session has no project entry yet.
382
- activeProject = name;
383
- pi.setSessionName(name);
384
- rememberSessionFile(name, targetSession);
385
-
386
- const path = projectPath(name);
387
- const branch = getGitBranch(path);
388
- const branchStr = branch ? ` on branch \`${branch}\`` : "";
389
- ctx.ui.notify(
390
- `Switched to ${name} — session restored: ${basename(targetSession)}\n` +
391
- `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
392
- "info"
393
- );
753
+ // rpc/json/print and any other surface: unchanged plain list
754
+ notifyPlainProjectList(ctx, projects);
394
755
  return;
395
756
  }
396
757
 
397
- // ── Fallback: no stored session (or file gone) -> same-session switch ─
398
- activeProject = name;
399
-
400
- // Persist to session
401
- pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
402
-
403
- // Update session name
404
- pi.setSessionName(name);
405
-
406
- const path = projectPath(name);
407
- const branch = getGitBranch(path);
408
- const branchStr = branch ? ` on branch \`${branch}\`` : "";
409
- const fromStr = previous ? ` (was: ${previous})` : "";
410
-
411
- // Current session identity — still valid on this path (no session replacement)
412
- const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
413
- const sessionLine = currentFile
414
- ? `Continuing session: ${basename(currentFile)}`
415
- : "Continuing current session";
416
-
417
- ctx.ui.notify(
418
- `Switched to ${name}${fromStr} — first session in this project\n` +
419
- `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}\n` +
420
- sessionLine,
421
- "info"
422
- );
423
-
424
- // Announce to the agent so it operates in the new context
425
- await ctx.waitForIdle();
426
- pi.sendUserMessage(
427
- `[Project switched to **${name}**]\n` +
428
- `Working directory: \`${path}\`${branchStr}\n` +
429
- `Please keep all file operations within this project from now on.`,
430
- { deliverAs: "followUp" }
431
- );
758
+ // ── Switch project ────────────────────────────────────────────────────
759
+ await switchToProject(name, ctx, createOptIn);
432
760
  },
433
761
  });
434
762
  }
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name": "pi-project-switcher", "version": "0.5.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"}}