osdy-pi 1.2.0 → 1.3.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 (44) hide show
  1. package/README.md +160 -30
  2. package/bin/osdy-pi.mjs +29 -6
  3. package/bin/osdy.mjs +43 -0
  4. package/extensions/osdy-pi/account-profiles.test.ts +111 -97
  5. package/extensions/osdy-pi/account-profiles.ts +91 -162
  6. package/extensions/osdy-pi/animation.test.ts +84 -0
  7. package/extensions/osdy-pi/animation.ts +17 -8
  8. package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
  9. package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
  10. package/extensions/osdy-pi/codex-usage.test.ts +276 -0
  11. package/extensions/osdy-pi/codex-usage.ts +276 -0
  12. package/extensions/osdy-pi/constants.ts +134 -34
  13. package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
  14. package/extensions/osdy-pi/context-usage-ui.ts +60 -0
  15. package/extensions/osdy-pi/editor-settings.test.ts +121 -23
  16. package/extensions/osdy-pi/editor-settings.ts +39 -3
  17. package/extensions/osdy-pi/metrics.test.ts +95 -0
  18. package/extensions/osdy-pi/metrics.ts +39 -1
  19. package/extensions/osdy-pi/profile-label.ts +2 -2
  20. package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
  21. package/extensions/osdy-pi/runtime-helpers.ts +42 -19
  22. package/extensions/osdy-pi/runtime.test.ts +355 -7
  23. package/extensions/osdy-pi/runtime.ts +344 -66
  24. package/extensions/osdy-pi/types.ts +26 -1
  25. package/extensions/osdy-pi/ui.test.ts +128 -0
  26. package/extensions/osdy-pi/ui.ts +57 -40
  27. package/package.json +5 -2
  28. package/scripts/osdy-pi-account-profiles.mjs +521 -76
  29. package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
  30. package/scripts/osdy-pi-profile-setup.mjs +175 -0
  31. package/themes/osdy-pi-catppuccin-frappe.json +6 -2
  32. package/themes/osdy-pi-catppuccin-latte.json +6 -2
  33. package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
  34. package/themes/osdy-pi-catppuccin-mocha.json +6 -2
  35. package/themes/osdy-pi-dark.json +6 -2
  36. package/themes/osdy-pi-dracula.json +6 -2
  37. package/themes/osdy-pi-kanagawa-dragon.json +6 -2
  38. package/themes/osdy-pi-kanagawa-lotus.json +6 -2
  39. package/themes/osdy-pi-kanagawa-wave.json +6 -2
  40. package/themes/osdy-pi-lucent-orange.json +6 -2
  41. package/themes/osdy-pi-matrix.json +6 -2
  42. package/themes/osdy-pi-new.json +6 -2
  43. package/themes/osdy-pi-sexy.json +6 -2
  44. package/themes/osdy-pi-tokyo-night.json +6 -2
@@ -1,27 +1,59 @@
1
1
  import assert from "node:assert/strict";
2
- import { EventEmitter } from "node:events";
2
+ import { mkdtemp, mkdir, realpath, symlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import test from "node:test";
4
- import type { ChildProcess } from "node:child_process";
5
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
6
- // @ts-ignore Node's native TypeScript runner resolves test-only TypeScript source imports.
7
- import { handoffToAccount, manageAccountProfile, manageAccountProfiles, parseDefaultAccountResult, waitForLauncherReady } from "./account-profiles.ts";
6
+ import {
7
+ availableProfiles,
8
+ manageAccountProfile,
9
+ manageAccountProfiles,
10
+ parseDefaultAccountResult,
11
+ sharedAgentDir,
12
+ switchAccountInPlace,
13
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
14
+ // @ts-ignore Node's native TypeScript runner resolves test-only TypeScript source imports.
15
+ } from "./account-profiles.ts";
8
16
 
9
- class FakeLauncher extends EventEmitter {
10
- disconnected = false;
11
- unreferenced = false;
12
-
13
- disconnect(): void {
14
- this.disconnected = true;
15
- }
17
+ void test("uses the account resolver for split-agent roots and explicit overrides", async () => {
18
+ const candidate = await mkdtemp(join(tmpdir(), "osdy-pi-extension-agent-test-"));
19
+ const managedParent = await mkdtemp(join(tmpdir(), "osdy-pi-extension-managed-test-"));
20
+ await mkdir(join(managedParent, "osdy-pi"));
21
+ await symlink(join(managedParent, "osdy-pi"), join(candidate, "osdy-pi"));
22
+ assert.equal(
23
+ await sharedAgentDir({ PI_CODING_AGENT_DIR: candidate }),
24
+ await realpath(managedParent),
25
+ );
26
+ const explicit = await mkdtemp(join(tmpdir(), "osdy-pi-extension-explicit-test-"));
27
+ assert.equal(
28
+ await sharedAgentDir({
29
+ OSDY_PI_SHARED_AGENT_DIR: explicit,
30
+ PI_CODING_AGENT_DIR: candidate,
31
+ }),
32
+ explicit,
33
+ );
34
+ });
16
35
 
17
- unref(): void {
18
- this.unreferenced = true;
19
- }
36
+ void test("does not traverse malformed agent roots during extension profile discovery", async () => {
37
+ const validCandidate = await mkdtemp(join(tmpdir(), "osdy-pi-extension-list-valid-test-"));
38
+ const managedParent = await mkdtemp(join(tmpdir(), "osdy-pi-extension-list-managed-test-"));
39
+ await mkdir(join(managedParent, "osdy-pi", "profiles", "work"), {
40
+ recursive: true,
41
+ });
42
+ await symlink(join(managedParent, "osdy-pi"), join(validCandidate, "osdy-pi"));
43
+ assert.deepEqual(
44
+ await availableProfiles(await sharedAgentDir({ PI_CODING_AGENT_DIR: validCandidate })),
45
+ ["work"],
46
+ );
20
47
 
21
- asChildProcess(): ChildProcess {
22
- return this as unknown as ChildProcess;
23
- }
24
- }
48
+ const malformedCandidate = await mkdtemp(join(tmpdir(), "osdy-pi-extension-list-malformed-test-"));
49
+ const wrongChild = await mkdtemp(join(tmpdir(), "osdy-pi-extension-list-wrong-child-test-"));
50
+ await mkdir(join(wrongChild, "profiles", "private"), { recursive: true });
51
+ await symlink(wrongChild, join(malformedCandidate, "osdy-pi"));
52
+ assert.deepEqual(
53
+ await availableProfiles(await sharedAgentDir({ PI_CODING_AGENT_DIR: malformedCandidate })),
54
+ [],
55
+ );
56
+ });
25
57
 
26
58
  void test("parses default account command output without treating status text as a profile", () => {
27
59
  assert.deepEqual(
@@ -265,6 +297,28 @@ void test("opens a looped account manager and exits cleanly on Cancel", async ()
265
297
  assert.deepEqual(notices, ["Accounts:\nwork (default)"]);
266
298
  });
267
299
 
300
+ void test("marks active and default profiles case-insensitively while preserving stored spelling", async () => {
301
+ const notices: string[] = [];
302
+ const answers = ["Account info", "Cancel"];
303
+ await manageAccountProfiles(
304
+ {
305
+ isIdle: () => true,
306
+ waitForIdle: () => Promise.resolve(),
307
+ ui: {
308
+ notify: (message: string) => notices.push(message),
309
+ select: () => Promise.resolve(answers.shift()),
310
+ input: () => Promise.resolve(undefined),
311
+ },
312
+ },
313
+ {
314
+ profiles: () => Promise.resolve(["Work"]),
315
+ activeProfile: "WORK",
316
+ run: () => Promise.resolve({ code: 0, stdout: "work\n", stderr: "" }),
317
+ },
318
+ );
319
+ assert.deepEqual(notices, ["Accounts:\nWork (active) (default)"]);
320
+ });
321
+
268
322
  void test("manages profile rename and permanent removal prompts through authoritative subprocess commands", async (t) => {
269
323
  await t.test("rename sequences target and new name", async () => {
270
324
  const calls: string[][] = [];
@@ -387,61 +441,15 @@ void test("manages profile rename and permanent removal prompts through authorit
387
441
  );
388
442
  });
389
443
 
390
- void test("waits for the Pi-ready IPC message before disconnecting the launcher", async () => {
391
- const launcher = new FakeLauncher();
392
- const ready = waitForLauncherReady(launcher.asChildProcess());
393
- assert.equal(launcher.disconnected, false);
394
- launcher.emit("spawn");
395
- assert.equal(launcher.disconnected, false);
396
- launcher.emit("message", { type: "osdy-pi-ready" });
397
- await ready;
398
- assert.equal(launcher.disconnected, true);
399
- assert.equal(launcher.unreferenced, true);
400
- });
401
-
402
- void test("rejects launcher error, early exit, and timeout before readiness", async (t) => {
403
- await t.test("error message", async () => {
404
- const launcher = new FakeLauncher();
405
- const ready = waitForLauncherReady(launcher.asChildProcess());
406
- launcher.emit("message", {
407
- type: "osdy-pi-error",
408
- message: "Pi spawn failed",
409
- });
410
- await assert.rejects(ready, /Pi spawn failed/);
411
- });
412
- await t.test("early exit", async () => {
413
- const launcher = new FakeLauncher();
414
- const ready = waitForLauncherReady(launcher.asChildProcess());
415
- launcher.emit("exit", 1, null);
416
- await assert.rejects(ready, /exited before confirming readiness/);
417
- });
418
- await t.test("timeout", async () => {
419
- const launcher = new FakeLauncher();
420
- let expire: (() => void) | undefined;
421
- const ready = waitForLauncherReady(
422
- launcher.asChildProcess(),
423
- 10,
424
- (callback) => {
425
- expire = callback;
426
- return setTimeout(() => undefined, 60_000);
427
- },
428
- );
429
- expire?.();
430
- await assert.rejects(ready, /timed out/);
431
- });
432
- });
433
-
434
- void test("account handoff waits for idle, starts the launcher with the current session, then shuts down", async () => {
444
+ void test("switches in place after idle without spawning or shutting down", async () => {
435
445
  const events: string[] = [];
436
- await handoffToAccount(
446
+ await switchAccountInPlace(
437
447
  {
438
448
  isIdle: () => false,
439
449
  waitForIdle: () =>
440
450
  Promise.resolve().then(() => {
441
451
  events.push("idle");
442
452
  }),
443
- shutdown: () => events.push("shutdown"),
444
- sessionManager: { getSessionFile: () => "/tmp/current.jsonl" },
445
453
  ui: {
446
454
  notify: (message: string) => events.push(message),
447
455
  select: () => Promise.resolve(undefined),
@@ -449,30 +457,34 @@ void test("account handoff waits for idle, starts the launcher with the current
449
457
  },
450
458
  },
451
459
  "work",
452
- {
453
- spawn: (command, args) =>
454
- Promise.resolve().then(() => {
455
- events.push(`${command} ${args.join(" ")}`);
456
- events.push("spawned");
457
- }),
460
+ (profile) =>
461
+ Promise.resolve().then(() => {
462
+ events.push(`activate ${profile}`);
463
+ }),
464
+ () =>
465
+ Promise.resolve().then(() => {
466
+ events.push("refresh");
467
+ }),
468
+ () => {
469
+ events.push(`render ${process.env.OSDY_PI_PROFILE_NAME}`);
458
470
  },
459
471
  );
460
472
  assert.deepEqual(events, [
461
473
  "idle",
462
- "osdy-pi account use work -- --session /tmp/current.jsonl",
463
- "spawned",
464
- "shutdown",
474
+ "activate work",
475
+ "render work",
476
+ "refresh",
477
+ "Switched to work. Your next request uses this account.",
465
478
  ]);
466
479
  });
467
480
 
468
- void test("account handoff keeps the current Pi session running when its session cannot be safely resumed", async () => {
481
+ void test("keeps the current account when in-place activation fails", async () => {
469
482
  const notices: string[] = [];
470
- await handoffToAccount(
483
+ let renderRequested = false;
484
+ await switchAccountInPlace(
471
485
  {
472
486
  isIdle: () => true,
473
487
  waitForIdle: () => Promise.resolve(),
474
- shutdown: () => assert.fail("must not shut down"),
475
- sessionManager: { getSessionFile: () => undefined },
476
488
  ui: {
477
489
  notify: (message: string) => notices.push(message),
478
490
  select: () => Promise.resolve(undefined),
@@ -480,38 +492,40 @@ void test("account handoff keeps the current Pi session running when its session
480
492
  },
481
493
  },
482
494
  "work",
483
- { spawn: () => Promise.resolve().then(() => assert.fail("must not launch")) },
495
+ () => Promise.reject(new Error("unsafe auth")),
496
+ () => Promise.resolve(assert.fail("must not refresh")),
497
+ () => {
498
+ renderRequested = true;
499
+ },
484
500
  );
501
+ assert.equal(renderRequested, false);
485
502
  assert.deepEqual(notices, [
486
- "Cannot switch accounts: the current session has not been saved yet.",
503
+ "Cannot switch accounts: the account files could not be safely activated.",
487
504
  ]);
488
505
  });
489
506
 
490
- void test("account handoff keeps the current Pi session running when the launcher fails asynchronously", async () => {
491
- const events: string[] = [];
492
- await handoffToAccount(
507
+ void test("reports a successful switch when usage refresh rejects unexpectedly", async () => {
508
+ const notices: string[] = [];
509
+ let refreshAttempted = false;
510
+ await switchAccountInPlace(
493
511
  {
494
512
  isIdle: () => true,
495
513
  waitForIdle: () => Promise.resolve(),
496
- shutdown: () => events.push("shutdown"),
497
- sessionManager: { getSessionFile: () => "/tmp/current.jsonl" },
498
514
  ui: {
499
- notify: (message: string) => events.push(message),
515
+ notify: (message: string) => notices.push(message),
500
516
  select: () => Promise.resolve(undefined),
501
517
  input: () => Promise.resolve(undefined),
502
518
  },
503
519
  },
504
520
  "work",
505
- {
506
- spawn: () =>
507
- Promise.resolve().then(() => {
508
- events.push("launch attempted");
509
- throw new Error("not found");
510
- }),
521
+ () => Promise.resolve(),
522
+ () => {
523
+ refreshAttempted = true;
524
+ return Promise.reject(new Error("unexpected refresh failure"));
511
525
  },
512
526
  );
513
- assert.deepEqual(events, [
514
- "launch attempted",
515
- "Cannot switch accounts: unable to start the replacement Pi process.",
527
+ assert.equal(refreshAttempted, true);
528
+ assert.deepEqual(notices, [
529
+ "Switched to work. Your next request uses this account.",
516
530
  ]);
517
531
  });
@@ -1,94 +1,75 @@
1
1
  import { spawn as spawnChild } from "node:child_process";
2
- import type { ChildProcess } from "node:child_process";
3
- import { readdir } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
2
  import { fileURLToPath } from "node:url";
6
- import { isAbsolute, join, resolve } from "node:path";
7
3
  import type {
8
4
  ExtensionCommandContext,
9
5
  ExtensionContext,
10
6
  } from "@earendil-works/pi-coding-agent";
11
7
 
12
- const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
8
+ const PROFILE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})$/;
13
9
  const RESERVED_PROFILE_NAMES = new Set(["default", "profiles", "auth.json"]);
14
10
 
11
+ function sameProfile(left: string | undefined, right: string | undefined): boolean {
12
+ return left?.toLowerCase() === right?.toLowerCase();
13
+ }
14
+
15
15
  export type AccountContext = Pick<
16
16
  ExtensionCommandContext,
17
- "isIdle" | "waitForIdle" | "shutdown"
17
+ "isIdle" | "waitForIdle"
18
18
  > & {
19
- sessionManager: Pick<
19
+ shutdown?: ExtensionCommandContext["shutdown"];
20
+ sessionManager?: Pick<
20
21
  ExtensionCommandContext["sessionManager"],
21
22
  "getSessionFile"
22
23
  >;
23
24
  ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input">;
24
25
  };
25
26
 
26
- export interface AccountHandoffDependencies {
27
- spawn(command: string, args: string[]): Promise<void>;
28
- launcher?: string;
29
- }
30
-
31
27
  export interface AccountManagementDependencies {
32
28
  profiles(): Promise<string[]>;
33
29
  run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }>;
34
30
  activeProfile?: string | undefined;
35
- handoff?: (profile: string) => Promise<void>;
31
+ activate?: (profile: string) => Promise<void>;
32
+ refreshUsage?: (() => Promise<void>) | undefined;
33
+ requestRender?: (() => void) | undefined;
36
34
  }
37
35
 
38
- type LauncherMessage =
39
- | { type: "osdy-pi-ready" }
40
- | { type: "osdy-pi-error"; message: string };
41
-
42
- type ScheduleTimeout = (callback: () => void, delay: number) => NodeJS.Timeout;
43
-
44
- const LAUNCHER_READY_TIMEOUT_MS = 10_000;
45
-
46
- function isProfileName(value: string): boolean {
47
- return PROFILE_NAME.test(value) && !RESERVED_PROFILE_NAMES.has(value);
48
- }
49
-
50
- function sharedAgentDir(env: NodeJS.ProcessEnv): string {
51
- return resolve(
52
- env.OSDY_PI_SHARED_AGENT_DIR ??
53
- env.PI_CODING_AGENT_DIR ??
54
- join(homedir(), ".pi", "agent"),
55
- );
36
+ export interface AccountProfilesCommandDependencies {
37
+ refreshUsage?: (() => Promise<void>) | undefined;
38
+ requestRender?: (() => void) | undefined;
56
39
  }
57
40
 
58
- async function availableProfiles(baseDir: string): Promise<string[]> {
59
- try {
60
- const entries = await readdir(join(baseDir, "osdy-pi", "profiles"), {
61
- withFileTypes: true,
62
- });
63
- const profiles: string[] = [];
64
- for (const entry of entries) {
65
- if (entry.isDirectory() && isProfileName(entry.name))
66
- profiles.push(entry.name);
67
- }
68
- return profiles.sort(compareStrings);
69
- } catch (error: unknown) {
70
- if (isErrorCode(error, "ENOENT")) return [];
71
- throw error;
72
- }
41
+ function isProfileName(value: string): boolean {
42
+ return PROFILE_NAME.test(value) && !RESERVED_PROFILE_NAMES.has(value.toLowerCase());
73
43
  }
74
44
 
75
- function isErrorCode(error: unknown, code: string): boolean {
76
- return (
77
- typeof error === "object" &&
78
- error !== null &&
79
- "code" in error &&
80
- error.code === code
81
- );
45
+ export async function sharedAgentDir(env: NodeJS.ProcessEnv): Promise<string> {
46
+ const moduleUrl = new URL(
47
+ "../../scripts/osdy-pi-account-profiles.mjs",
48
+ import.meta.url,
49
+ ).href;
50
+ const profiles = (await import(moduleUrl)) as {
51
+ getSharedAgentDir(env: NodeJS.ProcessEnv): string;
52
+ };
53
+ return profiles.getSharedAgentDir(env);
82
54
  }
83
55
 
84
- function compareStrings(left: string, right: string): number {
85
- return left < right ? -1 : left > right ? 1 : 0;
56
+ export async function availableProfiles(baseDir: string): Promise<string[]> {
57
+ const moduleUrl = new URL(
58
+ "../../scripts/osdy-pi-account-profiles.mjs",
59
+ import.meta.url,
60
+ ).href;
61
+ const profiles = (await import(moduleUrl)) as {
62
+ listProfiles(baseDir: string): Promise<string[]>;
63
+ };
64
+ return profiles.listProfiles(baseDir);
86
65
  }
87
66
 
88
- export async function handoffToAccount(
67
+ export async function switchAccountInPlace(
89
68
  ctx: AccountContext,
90
69
  profile: string,
91
- dependencies: AccountHandoffDependencies,
70
+ activate: (profile: string) => Promise<void>,
71
+ refreshUsage?: () => Promise<void>,
72
+ requestRender?: () => void,
92
73
  ): Promise<void> {
93
74
  if (!isProfileName(profile)) {
94
75
  ctx.ui.notify(
@@ -97,92 +78,29 @@ export async function handoffToAccount(
97
78
  );
98
79
  return;
99
80
  }
100
- const sessionPath = ctx.sessionManager.getSessionFile();
101
- if (!sessionPath || !isAbsolute(sessionPath)) {
102
- ctx.ui.notify(
103
- "Cannot switch accounts: the current session has not been saved yet.",
104
- "warning",
105
- );
106
- return;
107
- }
108
81
  if (!ctx.isIdle()) await ctx.waitForIdle();
109
- const launcher = dependencies.launcher ?? "osdy-pi";
110
82
  try {
111
- await dependencies.spawn(launcher, [
112
- "account",
113
- "use",
114
- profile,
115
- "--",
116
- "--session",
117
- sessionPath,
118
- ]);
83
+ await activate(profile);
119
84
  } catch {
120
85
  ctx.ui.notify(
121
- "Cannot switch accounts: unable to start the replacement Pi process.",
86
+ "Cannot switch accounts: the account files could not be safely activated.",
122
87
  "warning",
123
88
  );
124
89
  return;
125
90
  }
126
- ctx.shutdown();
127
- }
128
-
129
- function isLauncherMessage(message: unknown): message is LauncherMessage {
130
- if (typeof message !== "object" || message === null || !("type" in message))
131
- return false;
132
- if (message.type === "osdy-pi-ready") return true;
133
- return (
134
- message.type === "osdy-pi-error" &&
135
- "message" in message &&
136
- typeof message.message === "string"
91
+ process.env.OSDY_PI_PROFILE_NAME = profile;
92
+ requestRender?.();
93
+ try {
94
+ await refreshUsage?.();
95
+ } catch {
96
+ // Usage refresh failures must not make a completed account activation appear failed.
97
+ }
98
+ ctx.ui.notify(
99
+ `Switched to ${profile}. Your next request uses this account.`,
100
+ "info",
137
101
  );
138
102
  }
139
103
 
140
- export function waitForLauncherReady(
141
- child: ChildProcess,
142
- timeoutMs = LAUNCHER_READY_TIMEOUT_MS,
143
- scheduleTimeout: ScheduleTimeout = setTimeout,
144
- ): Promise<void> {
145
- return new Promise((resolveReady, rejectReady) => {
146
- let settled = false;
147
- const cleanup = (): void => {
148
- clearTimeout(timeout);
149
- child.removeListener("error", onError);
150
- child.removeListener("exit", onExit);
151
- child.removeListener("message", onMessage);
152
- };
153
- const fail = (error: Error): void => {
154
- if (settled) return;
155
- settled = true;
156
- cleanup();
157
- rejectReady(error);
158
- };
159
- const onError = (error: Error): void => fail(error);
160
- const onExit = (): void =>
161
- fail(new Error("Replacement Pi exited before confirming readiness."));
162
- const onMessage = (message: unknown): void => {
163
- if (!isLauncherMessage(message)) return;
164
- if (message.type === "osdy-pi-error") {
165
- fail(new Error(message.message));
166
- return;
167
- }
168
- if (settled) return;
169
- settled = true;
170
- cleanup();
171
- child.disconnect();
172
- child.unref();
173
- resolveReady();
174
- };
175
- const timeout = scheduleTimeout(
176
- () =>
177
- fail(new Error("Replacement Pi timed out before confirming readiness.")),
178
- timeoutMs,
179
- );
180
- child.once("error", onError);
181
- child.once("exit", onExit);
182
- child.on("message", onMessage);
183
- });
184
- }
185
-
186
104
  function bundledLauncherPath(): string {
187
105
  return fileURLToPath(new URL("../../bin/osdy-pi.mjs", import.meta.url));
188
106
  }
@@ -247,7 +165,7 @@ function markedProfiles(
247
165
  ): string[] {
248
166
  return profiles.map(
249
167
  (profile) =>
250
- `${profile}${profile === activeProfile ? " (active)" : ""}${profile === defaultProfile ? " (default)" : ""}`,
168
+ `${profile}${sameProfile(profile, activeProfile) ? " (active)" : ""}${sameProfile(profile, defaultProfile) ? " (default)" : ""}`,
251
169
  );
252
170
  }
253
171
 
@@ -290,7 +208,7 @@ export async function manageAccountProfile(
290
208
  const target =
291
209
  activeMarker === -1 ? selected : selected.slice(0, activeMarker);
292
210
  if (!profiles.includes(target)) return;
293
- if (target === dependencies.activeProfile) {
211
+ if (sameProfile(target, dependencies.activeProfile)) {
294
212
  ctx.ui.notify(
295
213
  "Cannot change the active profile. Switch to another profile first.",
296
214
  "warning",
@@ -302,7 +220,7 @@ export async function manageAccountProfile(
302
220
  if (newName === undefined) return;
303
221
  if (!validProfileName(newName)) {
304
222
  ctx.ui.notify(
305
- "Profile names use lowercase letters, numbers, and hyphens only.",
223
+ "Profile names use ASCII letters, numbers, and hyphens only.",
306
224
  "warning",
307
225
  );
308
226
  return;
@@ -331,10 +249,10 @@ export async function manageAccountProfile(
331
249
  return;
332
250
  }
333
251
  let replacement: string | undefined;
334
- if (defaultState.status === "valid" && defaultState.profile === target) {
252
+ if (defaultState.status === "valid" && sameProfile(defaultState.profile, target)) {
335
253
  replacement = await ctx.ui.select(
336
254
  "Choose a new default account",
337
- profiles.filter((profile) => profile !== target),
255
+ profiles.filter((profile) => !sameProfile(profile, target)),
338
256
  );
339
257
  if (replacement === undefined) return;
340
258
  }
@@ -404,7 +322,7 @@ export async function manageAccountProfiles(
404
322
  if (name === undefined) continue;
405
323
  if (!validProfileName(name)) {
406
324
  ctx.ui.notify(
407
- "Profile names use lowercase letters, numbers, and hyphens only.",
325
+ "Profile names use ASCII letters, numbers, and hyphens only.",
408
326
  "warning",
409
327
  );
410
328
  continue;
@@ -501,11 +419,18 @@ export async function manageAccountProfiles(
501
419
  const marker = target.indexOf(" (");
502
420
  const profile = marker === -1 ? target : target.slice(0, marker);
503
421
  if (!profiles.includes(profile)) continue;
504
- if (profile === dependencies.activeProfile) {
422
+ if (sameProfile(profile, dependencies.activeProfile)) {
505
423
  ctx.ui.notify("That account is already active.", "info");
506
424
  continue;
507
425
  }
508
- if (dependencies.handoff) await dependencies.handoff(profile);
426
+ if (dependencies.activate)
427
+ await switchAccountInPlace(
428
+ ctx,
429
+ profile,
430
+ dependencies.activate,
431
+ dependencies.refreshUsage,
432
+ dependencies.requestRender,
433
+ );
509
434
  return;
510
435
  }
511
436
  }
@@ -534,17 +459,20 @@ function runBundledCommand(
534
459
  });
535
460
  }
536
461
 
537
- export function registerAccountProfilesCommand(pi: {
538
- registerCommand(
539
- name: string,
540
- options: {
541
- description: string;
542
- handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
543
- },
544
- ): void;
545
- }): void {
462
+ export function registerAccountProfilesCommand(
463
+ pi: {
464
+ registerCommand(
465
+ name: string,
466
+ options: {
467
+ description: string;
468
+ handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
469
+ },
470
+ ): void;
471
+ },
472
+ commandDependencies: AccountProfilesCommandDependencies = {},
473
+ ): void {
546
474
  pi.registerCommand("osdy-account", {
547
- description: "Restart Pi with another isolated OpenAI account profile.",
475
+ description: "Switch the active OpenAI account without restarting Pi.",
548
476
  handler: async (args, ctx) => {
549
477
  const mode = args.trim();
550
478
  if ((ctx as ExtensionCommandContext & { hasUI?: boolean }).hasUI === false) {
@@ -552,20 +480,21 @@ export function registerAccountProfilesCommand(pi: {
552
480
  return;
553
481
  }
554
482
  const dependencies: AccountManagementDependencies = {
555
- profiles: () => availableProfiles(sharedAgentDir(process.env)),
483
+ profiles: async () => availableProfiles(await sharedAgentDir(process.env)),
556
484
  run: runBundledCommand,
557
485
  activeProfile: process.env.OSDY_PI_PROFILE_NAME,
558
- handoff: (profile) =>
559
- handoffToAccount(ctx, profile, {
560
- launcher: bundledLauncherPath(),
561
- spawn: (command, launchArgs) =>
562
- waitForLauncherReady(
563
- spawnChild(process.execPath, [command, ...launchArgs], {
564
- detached: true,
565
- stdio: ["ignore", "ignore", "ignore", "ipc"],
566
- }),
567
- ),
568
- }),
486
+ refreshUsage: commandDependencies.refreshUsage,
487
+ requestRender: commandDependencies.requestRender,
488
+ activate: async (profile) => {
489
+ const moduleUrl = new URL(
490
+ "../../scripts/osdy-pi-account-profiles.mjs",
491
+ import.meta.url,
492
+ ).href;
493
+ const profiles = (await import(moduleUrl)) as {
494
+ switchAccountAuth(baseDir: string, name: string): Promise<void>;
495
+ };
496
+ await profiles.switchAccountAuth(await sharedAgentDir(process.env), profile);
497
+ },
569
498
  };
570
499
  if (mode === "rename" || mode === "remove") {
571
500
  await manageAccountProfile(ctx, mode, dependencies);