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.
- package/README.md +160 -30
- package/bin/osdy-pi.mjs +29 -6
- package/bin/osdy.mjs +43 -0
- package/extensions/osdy-pi/account-profiles.test.ts +111 -97
- package/extensions/osdy-pi/account-profiles.ts +91 -162
- package/extensions/osdy-pi/animation.test.ts +84 -0
- package/extensions/osdy-pi/animation.ts +17 -8
- package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
- package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
- package/extensions/osdy-pi/codex-usage.test.ts +276 -0
- package/extensions/osdy-pi/codex-usage.ts +276 -0
- package/extensions/osdy-pi/constants.ts +134 -34
- package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
- package/extensions/osdy-pi/context-usage-ui.ts +60 -0
- package/extensions/osdy-pi/editor-settings.test.ts +121 -23
- package/extensions/osdy-pi/editor-settings.ts +39 -3
- package/extensions/osdy-pi/metrics.test.ts +95 -0
- package/extensions/osdy-pi/metrics.ts +39 -1
- package/extensions/osdy-pi/profile-label.ts +2 -2
- package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
- package/extensions/osdy-pi/runtime-helpers.ts +42 -19
- package/extensions/osdy-pi/runtime.test.ts +355 -7
- package/extensions/osdy-pi/runtime.ts +344 -66
- package/extensions/osdy-pi/types.ts +26 -1
- package/extensions/osdy-pi/ui.test.ts +128 -0
- package/extensions/osdy-pi/ui.ts +57 -40
- package/package.json +5 -2
- package/scripts/osdy-pi-account-profiles.mjs +521 -76
- package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
- package/scripts/osdy-pi-profile-setup.mjs +175 -0
- package/themes/osdy-pi-catppuccin-frappe.json +6 -2
- package/themes/osdy-pi-catppuccin-latte.json +6 -2
- package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
- package/themes/osdy-pi-catppuccin-mocha.json +6 -2
- package/themes/osdy-pi-dark.json +6 -2
- package/themes/osdy-pi-dracula.json +6 -2
- package/themes/osdy-pi-kanagawa-dragon.json +6 -2
- package/themes/osdy-pi-kanagawa-lotus.json +6 -2
- package/themes/osdy-pi-kanagawa-wave.json +6 -2
- package/themes/osdy-pi-lucent-orange.json +6 -2
- package/themes/osdy-pi-matrix.json +6 -2
- package/themes/osdy-pi-new.json +6 -2
- package/themes/osdy-pi-sexy.json +6 -2
- package/themes/osdy-pi-tokyo-night.json +6 -2
|
@@ -1,27 +1,59 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
"
|
|
463
|
-
"
|
|
464
|
-
"
|
|
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("
|
|
481
|
+
void test("keeps the current account when in-place activation fails", async () => {
|
|
469
482
|
const notices: string[] = [];
|
|
470
|
-
|
|
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
|
-
|
|
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
|
|
503
|
+
"Cannot switch accounts: the account files could not be safely activated.",
|
|
487
504
|
]);
|
|
488
505
|
});
|
|
489
506
|
|
|
490
|
-
void test("
|
|
491
|
-
const
|
|
492
|
-
|
|
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) =>
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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.
|
|
514
|
-
|
|
515
|
-
"
|
|
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 = /^[
|
|
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"
|
|
17
|
+
"isIdle" | "waitForIdle"
|
|
18
18
|
> & {
|
|
19
|
-
|
|
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
|
-
|
|
31
|
+
activate?: (profile: string) => Promise<void>;
|
|
32
|
+
refreshUsage?: (() => Promise<void>) | undefined;
|
|
33
|
+
requestRender?: (() => void) | undefined;
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
85
|
-
|
|
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
|
|
67
|
+
export async function switchAccountInPlace(
|
|
89
68
|
ctx: AccountContext,
|
|
90
69
|
profile: string,
|
|
91
|
-
|
|
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
|
|
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:
|
|
86
|
+
"Cannot switch accounts: the account files could not be safely activated.",
|
|
122
87
|
"warning",
|
|
123
88
|
);
|
|
124
89
|
return;
|
|
125
90
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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: "
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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);
|