osdy-pi 1.11.2 → 1.11.4
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 +73 -9
- package/extensions/osdy-pi/account-profiles.test.ts +17 -0
- package/extensions/osdy-pi/account-profiles.ts +82 -22
- package/extensions/osdy-pi/constants.ts +18 -12
- package/extensions/osdy-pi/control-center-account.test.ts +241 -0
- package/extensions/osdy-pi/control-center-account.ts +141 -0
- package/extensions/osdy-pi/control-center-agents.test.ts +107 -0
- package/extensions/osdy-pi/control-center-agents.ts +60 -0
- package/extensions/osdy-pi/control-center-git.test.ts +48 -0
- package/extensions/osdy-pi/control-center-git.ts +40 -0
- package/extensions/osdy-pi/control-center-preferences.test.ts +48 -0
- package/extensions/osdy-pi/control-center-preferences.ts +57 -0
- package/extensions/osdy-pi/control-center-sounds.test.ts +120 -0
- package/extensions/osdy-pi/control-center-sounds.ts +87 -0
- package/extensions/osdy-pi/control-center-todo.test.ts +68 -0
- package/extensions/osdy-pi/control-center-todo.ts +48 -0
- package/extensions/osdy-pi/control-center-usage.test.ts +90 -0
- package/extensions/osdy-pi/control-center-usage.ts +69 -0
- package/extensions/osdy-pi/control-center.test.ts +625 -0
- package/extensions/osdy-pi/control-center.ts +501 -0
- package/extensions/osdy-pi/profile-codex-usage.test.ts +218 -0
- package/extensions/osdy-pi/profile-codex-usage.ts +69 -0
- package/extensions/osdy-pi/runtime-helpers.test.ts +1 -0
- package/extensions/osdy-pi/runtime-helpers.ts +40 -13
- package/extensions/osdy-pi/runtime.test.ts +885 -3
- package/extensions/osdy-pi/runtime.ts +165 -29
- package/extensions/osdy-pi/types.ts +15 -0
- package/extensions/osdy-pi/ui.test.ts +182 -2
- package/extensions/osdy-pi/ui.ts +55 -51
- package/extensions/osdy-pi/usage-analytics.test.ts +12 -2
- package/extensions/osdy-pi/usage-analytics.ts +2 -1
- package/extensions/osdy-pi/working-activity.ts +57 -0
- package/extensions/osdy-pi/working-animation.test.ts +83 -29
- package/extensions/osdy-pi/working-animation.ts +6 -4
- package/extensions/osdy-pi/working-controller.test.ts +115 -0
- package/extensions/osdy-pi/working-controller.ts +26 -25
- package/package.json +2 -2
- package/scripts/osdy-pi-account-profiles.mjs +40 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import type { ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
8
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
9
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
10
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
11
|
+
}
|
|
12
|
+
return next(specifier, context);
|
|
13
|
+
} });
|
|
14
|
+
const { createControlCenterAccount, bindControlCenterAccount } = await import("./control-center-account.js");
|
|
15
|
+
|
|
16
|
+
void test("Profile preview is explicit, independently selected and never activates", async () => {
|
|
17
|
+
const requests: string[] = [];
|
|
18
|
+
const service = createControlCenterAccount({
|
|
19
|
+
profiles: () => Promise.resolve(["work", "personal"]), active: () => "work",
|
|
20
|
+
defaultProfile: () => Promise.resolve(undefined),
|
|
21
|
+
switch: () => { throw new Error("activation forbidden"); }, setDefault: () => { throw new Error("write forbidden"); },
|
|
22
|
+
usage: profile => {
|
|
23
|
+
requests.push(profile);
|
|
24
|
+
return Promise.resolve({ status: "ready", profile, checkedAt: 1000, quotaSnapshot: { fetchedAt: 1000,
|
|
25
|
+
planType: undefined, ordinaryUsageAllowed: undefined, credits: undefined,
|
|
26
|
+
buckets: [
|
|
27
|
+
{ id: "codex", label: undefined, primary: { usedPercent: 25, windowMinutes: 300, resetsAt: 2000 }, secondary: { usedPercent: 80, windowMinutes: 10080, resetsAt: undefined } },
|
|
28
|
+
{ id: "extra", label: "Extra bucket", primary: { usedPercent: 10, windowMinutes: undefined, resetsAt: undefined }, secondary: undefined },
|
|
29
|
+
] } });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const initial = await service.read();
|
|
33
|
+
assert.deepEqual(requests, []);
|
|
34
|
+
assert.equal(initial.rows[0]?.label, "View usage: work");
|
|
35
|
+
assert.ok(initial.rows.findIndex(row => row.label === "View usage: personal") < initial.rows.findIndex(row => row.label === "Switch to personal"));
|
|
36
|
+
for (const profile of ["work", "personal"]) {
|
|
37
|
+
assert.equal((await service.apply({ kind: "account-usage", profile })).failed, false);
|
|
38
|
+
const row = (await service.read()).rows.find(row => row.label === `View usage: ${profile}`);
|
|
39
|
+
assert.match(row?.details?.join(" ") ?? "", /75% remaining/);
|
|
40
|
+
assert.match(row?.details?.join(" ") ?? "", /Weekly: 20% remaining.*Next reset: unknown/);
|
|
41
|
+
assert.match(row?.details?.join(" ") ?? "", /Extra bucket.*90% remaining.*Next reset: unknown/);
|
|
42
|
+
assert.ok(row?.details?.includes(`Profile: ${profile}`));
|
|
43
|
+
assert.ok(row?.details?.includes(`Checked: ${new Date(1000).toLocaleString()} (local time)`));
|
|
44
|
+
assert.ok(row?.details?.some(line => line.endsWith(`Next reset: ${new Date(2000 * 1000).toLocaleString()}`)));
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(requests, ["work", "personal"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
void test("Profile preview preserves timestamp units and marks missing or invalid resets unknown", async () => {
|
|
50
|
+
const checkedAt = 1_780_000_000_123;
|
|
51
|
+
for (const resetsAt of [1_780_000_000, 0, undefined, NaN, Infinity, -Infinity, Number.MAX_VALUE]) {
|
|
52
|
+
const service = createControlCenterAccount({
|
|
53
|
+
profiles: () => Promise.resolve(["work"]), active: () => "work",
|
|
54
|
+
defaultProfile: () => Promise.resolve(undefined),
|
|
55
|
+
switch: () => { throw new Error("activation forbidden"); },
|
|
56
|
+
setDefault: () => { throw new Error("write forbidden"); },
|
|
57
|
+
usage: profile => Promise.resolve({ status: "ready", profile, checkedAt,
|
|
58
|
+
quotaSnapshot: { fetchedAt: checkedAt, planType: undefined, ordinaryUsageAllowed: undefined, credits: undefined,
|
|
59
|
+
buckets: [{ id: "codex", label: undefined,
|
|
60
|
+
primary: { usedPercent: 25, windowMinutes: 300, resetsAt },
|
|
61
|
+
secondary: { usedPercent: 80, windowMinutes: 10080, resetsAt } }] } }),
|
|
62
|
+
});
|
|
63
|
+
await service.apply({ kind: "account-usage", profile: "work" });
|
|
64
|
+
const details = (await service.read()).rows[0]?.details ?? [];
|
|
65
|
+
assert.ok(details.includes(`Checked: ${new Date(checkedAt).toLocaleString()} (local time)`));
|
|
66
|
+
const expectedReset = resetsAt === 1_780_000_000 || resetsAt === 0
|
|
67
|
+
? new Date(resetsAt * 1000).toLocaleString() : "unknown";
|
|
68
|
+
for (const label of ["Session: 75", "Weekly: 20"]) {
|
|
69
|
+
assert.ok(details.includes(`codex / ${label}% remaining · Next reset: ${expectedReset}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
void test("Account projects validated metadata, never output or credential fields", async () => {
|
|
75
|
+
const service = createControlCenterAccount({
|
|
76
|
+
profiles: () => Promise.resolve(["work", "personal", "../auth.json", { token: "secret" }]),
|
|
77
|
+
active: () => "work", defaultProfile: () => Promise.resolve({ code: 0, stdout: "personal", stderr: "secret" }),
|
|
78
|
+
switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true),
|
|
79
|
+
});
|
|
80
|
+
const view = await service.read();
|
|
81
|
+
assert.match(view.summary, /Current: work.*Default: personal/);
|
|
82
|
+
assert.equal(JSON.stringify(view).includes("secret"), false);
|
|
83
|
+
assert.equal(JSON.stringify(view).includes("auth.json"), false);
|
|
84
|
+
assert.equal(view.rows.some(row => row.label === "Switch to personal"), true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
void test("Account revalidates membership and uses real switch/default adapters", async () => {
|
|
88
|
+
const calls: string[] = [];
|
|
89
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => undefined,
|
|
90
|
+
defaultProfile: () => Promise.resolve({ code: 1, stdout: "secret", stderr: "secret" }),
|
|
91
|
+
switch: name => { calls.push(`switch:${name}`); return Promise.resolve(true); },
|
|
92
|
+
setDefault: name => { calls.push(`default:${name ?? "clear"}`); return Promise.resolve(true); },
|
|
93
|
+
});
|
|
94
|
+
assert.equal((await service.read()).summary.includes("secret"), false);
|
|
95
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "missing" })).failed, true);
|
|
96
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, false);
|
|
97
|
+
assert.equal((await service.apply({ kind: "account-default", profile: "work" })).failed, false);
|
|
98
|
+
assert.deepEqual(calls, ["switch:work", "default:work"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
void test("Unavailable default metadata cannot mark a profile named unavailable as default", async () => {
|
|
102
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["unavailable"]), active: () => undefined,
|
|
103
|
+
defaultProfile: () => Promise.resolve({ code: 1, stdout: "", stderr: "" }),
|
|
104
|
+
switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true) });
|
|
105
|
+
assert.equal((await service.read()).rows.some(row => row.current), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
void test("Bound Account reuses idle switching, quota refresh, and authoritative default commands", async () => {
|
|
109
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
110
|
+
process.env.OSDY_PI_PROFILE_NAME = "personal";
|
|
111
|
+
const events: string[] = [];
|
|
112
|
+
const commands: string[][] = [];
|
|
113
|
+
let idle = false;
|
|
114
|
+
const service = bindControlCenterAccount({ isIdle: () => idle,
|
|
115
|
+
waitForIdle: () => { events.push("idle"); idle = true; return Promise.resolve(); },
|
|
116
|
+
ui: { notify: text => events.push(text), select: () => Promise.reject(new Error("old dialog forbidden")), input: () => Promise.reject(new Error("old dialog forbidden")) } },
|
|
117
|
+
() => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); },
|
|
118
|
+
{ profiles: () => Promise.resolve(["work", "personal"]), activate: name => { events.push(`activate:${name}`); return Promise.resolve(); },
|
|
119
|
+
run: args => { commands.push(args); return Promise.resolve({ code: 0, stdout: "work", stderr: "secret" }); } });
|
|
120
|
+
try {
|
|
121
|
+
await service.read();
|
|
122
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, false);
|
|
123
|
+
assert.deepEqual(events.slice(0, 4), ["idle", "activate:work", "render", "refresh"]);
|
|
124
|
+
assert.equal(process.env.OSDY_PI_PROFILE_NAME, "work");
|
|
125
|
+
await service.apply({ kind: "account-default", profile: "work" });
|
|
126
|
+
await service.apply({ kind: "account-default", profile: undefined });
|
|
127
|
+
assert.deepEqual(commands, [["account", "default"], ["account", "default", "work"], ["account", "default", "--clear"]]);
|
|
128
|
+
assert.equal(events.join(" ").includes("secret"), false);
|
|
129
|
+
} finally {
|
|
130
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
131
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
void test("Bound Account validates live external active names without rendering unsafe metadata", async () => {
|
|
136
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
137
|
+
const service = bindControlCenterAccount({ isIdle: () => true, waitForIdle: () => Promise.resolve(),
|
|
138
|
+
ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) } },
|
|
139
|
+
() => Promise.resolve(), () => {},
|
|
140
|
+
{ profiles: () => Promise.resolve(["work"]), run: () => Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }) });
|
|
141
|
+
try {
|
|
142
|
+
for (const value of [undefined, "", "../auth.json", "auth.json", "DEFAULT", "token-secret\n", "x".repeat(64)]) {
|
|
143
|
+
if (value === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
144
|
+
else process.env.OSDY_PI_PROFILE_NAME = value;
|
|
145
|
+
const view = await service.read();
|
|
146
|
+
assert.match(view.summary, /Current: unmanaged/);
|
|
147
|
+
assert.equal(view.rows.some(row => row.label === "Switch to work"), true);
|
|
148
|
+
assert.equal(JSON.stringify(view).includes("token-secret"), false);
|
|
149
|
+
assert.equal(JSON.stringify(view).includes("auth.json"), false);
|
|
150
|
+
}
|
|
151
|
+
process.env.OSDY_PI_PROFILE_NAME = "Work";
|
|
152
|
+
assert.match((await service.read()).summary, /Current: Work/);
|
|
153
|
+
assert.equal((await service.read()).rows.some(row => row.label === "Switch to work"), false);
|
|
154
|
+
} finally {
|
|
155
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
156
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
void test("Account busy guarding rejects overlap without cancelling the first operation", async () => {
|
|
161
|
+
let finish: (value: boolean) => void = () => {};
|
|
162
|
+
let switches = 0;
|
|
163
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => undefined,
|
|
164
|
+
defaultProfile: () => Promise.resolve(undefined), setDefault: () => Promise.resolve(true),
|
|
165
|
+
switch: () => { switches++; return new Promise(resolve => { finish = resolve; }); } });
|
|
166
|
+
const first = service.apply({ kind: "account-switch", profile: "work" });
|
|
167
|
+
await Promise.resolve(); await Promise.resolve();
|
|
168
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, true);
|
|
169
|
+
assert.equal(switches, 1); finish(true); assert.equal((await first).failed, false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
void test("Account errors are fixed messages and never leak backend errors", async () => {
|
|
173
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => "work",
|
|
174
|
+
defaultProfile: () => Promise.reject(new Error("token-secret")),
|
|
175
|
+
switch: () => Promise.reject(new Error("token-secret")), setDefault: () => Promise.resolve(false),
|
|
176
|
+
});
|
|
177
|
+
assert.equal(JSON.stringify(await service.read()).includes("token-secret"), false);
|
|
178
|
+
const result = await service.apply({ kind: "account-default", profile: "work" });
|
|
179
|
+
assert.equal(result.failed, true);
|
|
180
|
+
assert.equal(result.message.includes("token-secret"), false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
void test("Profile preview cancellation, supersession and stale runtime discard ignored-abort results", async () => {
|
|
184
|
+
const requests: { signal: AbortSignal; finish: (result: ProfileCodexUsageResult) => void }[] = [];
|
|
185
|
+
let current = true;
|
|
186
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work", "personal"]), active: () => "work",
|
|
187
|
+
defaultProfile: () => Promise.resolve(undefined), switch: () => { throw new Error("forbidden"); }, setDefault: () => { throw new Error("forbidden"); },
|
|
188
|
+
isCurrent: () => current, usage: (_profile, signal) => new Promise(finish => requests.push({ signal, finish })),
|
|
189
|
+
});
|
|
190
|
+
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
|
|
191
|
+
const first = service.apply({ kind: "account-usage", profile: "work" }); await flush();
|
|
192
|
+
assert.equal((await service.apply({ kind: "account-default", profile: undefined })).failed, true);
|
|
193
|
+
const second = service.apply({ kind: "account-usage", profile: "personal" }); await flush();
|
|
194
|
+
assert.equal(requests[0]?.signal.aborted, true);
|
|
195
|
+
requests[1]?.finish({ status: "unavailable", profile: "personal", checkedAt: 1000, reason: "remote-usage-unavailable" });
|
|
196
|
+
await second;
|
|
197
|
+
requests[0]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await first;
|
|
198
|
+
assert.match(JSON.stringify(await service.read()), /Remote Codex usage unavailable/);
|
|
199
|
+
assert.doesNotMatch(JSON.stringify(await service.read()), /Stored credentials unavailable \(missing/);
|
|
200
|
+
const third = service.apply({ kind: "account-usage", profile: "work" }); await flush();
|
|
201
|
+
service.cancelUsage?.(); assert.equal(requests[2]?.signal.aborted, true);
|
|
202
|
+
requests[2]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await third;
|
|
203
|
+
assert.doesNotMatch(JSON.stringify(await service.read()), /Checked:/);
|
|
204
|
+
const fourth = service.apply({ kind: "account-usage", profile: "work" }); await flush(); current = false;
|
|
205
|
+
requests[3]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await fourth;
|
|
206
|
+
assert.doesNotMatch(JSON.stringify(await service.read()), /Checked:/);
|
|
207
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "personal" })).failed, true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
void test("Unavailable, cancelled, absent quota and unexpected errors never fabricate zero or leak raw errors", async () => {
|
|
211
|
+
for (const outcome of ["stored-credentials-unavailable", "remote-usage-unavailable", "cancelled", "empty", "error"] as const) {
|
|
212
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => "work",
|
|
213
|
+
defaultProfile: () => Promise.resolve(undefined), switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true),
|
|
214
|
+
usage: profile => {
|
|
215
|
+
if (outcome === "error") throw new Error("/private/auth.json token-secret");
|
|
216
|
+
if (outcome === "empty") return Promise.resolve({ status: "ready", profile, checkedAt: 1000,
|
|
217
|
+
quotaSnapshot: { fetchedAt: 1000, buckets: [], credits: undefined, planType: undefined, ordinaryUsageAllowed: undefined } });
|
|
218
|
+
if (outcome === "cancelled") return Promise.resolve({ status: "cancelled", profile, checkedAt: 1000, reason: "cancelled" });
|
|
219
|
+
return Promise.resolve({ status: "unavailable", profile, checkedAt: 1000, reason: outcome });
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
await service.apply({ kind: "account-usage", profile: "work" });
|
|
223
|
+
const text = JSON.stringify(await service.read());
|
|
224
|
+
assert.doesNotMatch(text, /0%|token-secret|private\/auth/);
|
|
225
|
+
assert.match(text, outcome === "empty" ? /unknown/ : /unavailable|cancelled/);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
void test("Bound preview uses injected service independently of active refresh and queued stale activation", async () => {
|
|
230
|
+
let current = true; let release = () => {}; const names: string[] = [];
|
|
231
|
+
const service = bindControlCenterAccount({ isIdle: () => false, waitForIdle: () => new Promise<void>(resolve => { release = resolve; }),
|
|
232
|
+
ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) } },
|
|
233
|
+
() => { throw new Error("active quota forbidden"); }, () => {},
|
|
234
|
+
{ profiles: () => Promise.resolve(["work"]), activate: () => { throw new Error("stale activation forbidden"); }, run: () => Promise.resolve({ code: 0, stdout: "", stderr: "" }) },
|
|
235
|
+
{ isCurrent: () => current, usage: (profile, signal) => { names.push(profile); assert.equal(signal.aborted, false);
|
|
236
|
+
return Promise.resolve({ status: "unavailable", profile, checkedAt: 1000, reason: "stored-credentials-unavailable" }); } });
|
|
237
|
+
await service.apply({ kind: "account-usage", profile: "work" }); assert.deepEqual(names, ["work"]);
|
|
238
|
+
const switching = service.apply({ kind: "account-switch", profile: "work" });
|
|
239
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
240
|
+
current = false; release(); assert.equal((await switching).failed, true);
|
|
241
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createAccountManagementDependencies, isProfileName, readActiveProfileName, readStoredProfileCodexCredential, sharedAgentDir, switchAccountInPlace, type AccountContext, type AccountManagementDependencies } from "./account-profiles.js";
|
|
2
|
+
import type { ControlCenterService, ControlCenterRow, ControlCenterAccountAction } from "./control-center.js";
|
|
3
|
+
import { requestProfileCodexUsage, type ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
4
|
+
|
|
5
|
+
export interface ControlCenterAccountService extends ControlCenterService<ControlCenterAccountAction> {
|
|
6
|
+
cancelUsage?(): void;
|
|
7
|
+
isCurrent?(): boolean;
|
|
8
|
+
}
|
|
9
|
+
function localTime(value: number | undefined): string {
|
|
10
|
+
return value !== undefined && Number.isFinite(value) && !Number.isNaN(new Date(value).getTime())
|
|
11
|
+
? new Date(value).toLocaleString() : "unknown";
|
|
12
|
+
}
|
|
13
|
+
function localResetTime(unixSeconds: number | undefined): string {
|
|
14
|
+
return localTime(unixSeconds === undefined ? undefined : unixSeconds * 1000);
|
|
15
|
+
}
|
|
16
|
+
function usageDetails(profile: string, result: ProfileCodexUsageResult): string[] {
|
|
17
|
+
const lines = [`Profile: ${profile}`, `Checked: ${localTime(result.checkedAt)} (local time)`];
|
|
18
|
+
if (result.status !== "ready") return [...lines, result.status === "cancelled" ? "Query cancelled." :
|
|
19
|
+
result.reason === "remote-usage-unavailable" ? "Remote Codex usage unavailable (credentials may be rejected)." : "Stored credentials unavailable (missing, expired or unsupported)."];
|
|
20
|
+
for (const bucket of result.quotaSnapshot.buckets) {
|
|
21
|
+
if (!bucket.primary && !bucket.secondary) lines.push(`${bucket.label ?? bucket.id}: quota and next reset unknown.`);
|
|
22
|
+
for (const [label, window] of [["Session", bucket.primary], ["Weekly", bucket.secondary]] as const) {
|
|
23
|
+
if (window) lines.push(`${bucket.label ?? bucket.id} / ${label}: ${100 - window.usedPercent}% remaining · Next reset: ${localResetTime(window.resetsAt)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (lines.length === 2) lines.push("Quota and next reset: unknown (no quota windows returned).");
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ControlCenterAccountBackend {
|
|
31
|
+
profiles(): Promise<unknown>;
|
|
32
|
+
active(): string | undefined;
|
|
33
|
+
defaultProfile(): Promise<unknown>;
|
|
34
|
+
switch(profile: string): Promise<boolean>;
|
|
35
|
+
setDefault(profile: string | undefined): Promise<boolean>;
|
|
36
|
+
usage?(profile: string, signal: AbortSignal): Promise<ProfileCodexUsageResult>;
|
|
37
|
+
isCurrent?(): boolean;
|
|
38
|
+
}
|
|
39
|
+
function names(value: unknown): string[] {
|
|
40
|
+
return Array.isArray(value) ? value.filter((name: unknown): name is string => typeof name === "string" && isProfileName(name)) : [];
|
|
41
|
+
}
|
|
42
|
+
function safeName(value: unknown): string | undefined {
|
|
43
|
+
return typeof value === "string" && isProfileName(value) ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
type DefaultMetadata = { kind: "profile"; name: string } | { kind: "unset" | "unavailable" };
|
|
46
|
+
function defaultMetadata(value: unknown): DefaultMetadata {
|
|
47
|
+
if (typeof value !== "object" || value === null || !("code" in value) || value.code !== 0 || !("stdout" in value) || typeof value.stdout !== "string") return { kind: "unavailable" };
|
|
48
|
+
const output = value.stdout.trim();
|
|
49
|
+
if (output === "No default account.") return { kind: "unset" };
|
|
50
|
+
const name = safeName(output);
|
|
51
|
+
return name ? { kind: "profile", name } : { kind: "unavailable" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Metadata-only projection. Never interpolate backend stdout/stderr/errors. */
|
|
55
|
+
export function createControlCenterAccount(backend: ControlCenterAccountBackend): ControlCenterAccountService {
|
|
56
|
+
let busy = false;
|
|
57
|
+
let controller: AbortController | undefined;
|
|
58
|
+
let preview: { profile: string; details: string[] } | undefined;
|
|
59
|
+
const current = () => backend.isCurrent?.() ?? true;
|
|
60
|
+
const cancelUsage = () => { controller?.abort(); controller = undefined; preview = undefined; };
|
|
61
|
+
return {
|
|
62
|
+
cancelUsage,
|
|
63
|
+
isCurrent: current,
|
|
64
|
+
async read() {
|
|
65
|
+
let profiles: string[];
|
|
66
|
+
try { profiles = names(await backend.profiles()); }
|
|
67
|
+
catch { return { summary: "Account profiles unavailable", note: "Reopen to retry. Login remains Pi-owned (/login).", rows: [] }; }
|
|
68
|
+
const active = safeName(backend.active());
|
|
69
|
+
let metadata: DefaultMetadata = { kind: "unavailable" };
|
|
70
|
+
try { metadata = defaultMetadata(await backend.defaultProfile()); } catch { /* Fixed metadata state only. */ }
|
|
71
|
+
const defaultProfile = metadata.kind === "profile" ? metadata.name : undefined;
|
|
72
|
+
const rows: ControlCenterRow[] = [];
|
|
73
|
+
for (const profile of profiles) {
|
|
74
|
+
rows.push({ label: `View usage: ${profile}`, current: false, action: { kind: "account-usage", profile },
|
|
75
|
+
details: preview?.profile === profile && (backend.isCurrent?.() ?? true) ? preview.details : [`Profile: ${profile}`, "Explicit live Codex query using this profile's stored credentials snapshot; no automatic refresh.", "Missing or expired snapshots are unavailable. Local token history is not quota."] });
|
|
76
|
+
const current = profile.toLowerCase() === active?.toLowerCase();
|
|
77
|
+
if (!current) rows.push({ label: `Switch to ${profile}`, current: false, action: { kind: "account-switch", profile }, details: ["Switch in place after Pi is idle; next request uses this account."] });
|
|
78
|
+
rows.push({ label: `Default: ${profile}`, current: profile.toLowerCase() === defaultProfile?.toLowerCase(), action: { kind: "account-default", profile }, details: [`Profile: ${profile}${current ? " (active)" : ""}`, "Default selects the account for future launcher startup."] });
|
|
79
|
+
}
|
|
80
|
+
rows.push({ label: "Clear default", current: false, action: { kind: "account-default", profile: undefined } });
|
|
81
|
+
return { summary: `Current: ${active ?? "unmanaged"} | Default: ${defaultProfile ?? metadata.kind} | Profiles: ${profiles.length}`,
|
|
82
|
+
note: "View usage queries Codex only; other actions require confirmation. Login remains Pi-owned (/login).", rows };
|
|
83
|
+
},
|
|
84
|
+
async apply(action) {
|
|
85
|
+
if (action.kind === "account-usage") {
|
|
86
|
+
if (busy || !current() || !backend.usage || !isProfileName(action.profile)) return { failed: true, message: "Profile usage unavailable." };
|
|
87
|
+
cancelUsage();
|
|
88
|
+
const request = new AbortController();
|
|
89
|
+
controller = request;
|
|
90
|
+
try {
|
|
91
|
+
if (!names(await backend.profiles()).includes(action.profile) || request.signal.aborted || !current()) return { failed: true, message: "Profile usage unavailable." };
|
|
92
|
+
const result = await backend.usage(action.profile, request.signal);
|
|
93
|
+
if (controller !== request || request.signal.aborted || !current()) return { failed: true, message: "Query cancelled." };
|
|
94
|
+
preview = { profile: action.profile, details: usageDetails(action.profile, result) };
|
|
95
|
+
return { failed: result.status !== "ready", message: result.status === "ready" ? "Stored-profile Codex usage checked." : "Profile usage unavailable." };
|
|
96
|
+
} catch {
|
|
97
|
+
if (controller === request && !request.signal.aborted && current()) preview = { profile: action.profile, details: [`Profile: ${action.profile}`, "Checked: unknown", "Profile usage unavailable."] };
|
|
98
|
+
return { failed: true, message: "Profile usage unavailable." };
|
|
99
|
+
} finally { if (controller === request) controller = undefined; }
|
|
100
|
+
}
|
|
101
|
+
if (busy || controller || !current() || (action.kind !== "account-switch" && action.kind !== "account-default")) return { failed: true, message: "Account action unavailable." };
|
|
102
|
+
busy = true;
|
|
103
|
+
try {
|
|
104
|
+
const profile = action.profile;
|
|
105
|
+
if (profile !== undefined && (!isProfileName(profile) || !names(await backend.profiles()).includes(profile))) return { failed: true, message: "Profile no longer available. Reopen Account." };
|
|
106
|
+
if (!current()) return { failed: true, message: "Account action unavailable." };
|
|
107
|
+
if (action.kind === "account-switch" && action.profile.toLowerCase() === safeName(backend.active())?.toLowerCase()) return { failed: false, message: "Account already active." };
|
|
108
|
+
const success = action.kind === "account-switch" ? await backend.switch(action.profile) : await backend.setDefault(profile);
|
|
109
|
+
if (!success) return { failed: true, message: "Account operation failed; change not confirmed." };
|
|
110
|
+
const message = action.kind === "account-switch"
|
|
111
|
+
? "Account switched; next request uses it."
|
|
112
|
+
: "Default account saved.";
|
|
113
|
+
return { failed: false, message };
|
|
114
|
+
} catch { return { failed: true, message: "Account operation failed; change not confirmed." }; }
|
|
115
|
+
finally { busy = false; }
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function bindControlCenterAccount(ctx: AccountContext, refreshUsage: () => Promise<void>, requestRender: () => void,
|
|
121
|
+
backend: AccountManagementDependencies = createAccountManagementDependencies({ refreshUsage, requestRender }),
|
|
122
|
+
options: { isCurrent?: () => boolean; usage?: ControlCenterAccountBackend["usage"] } = {},
|
|
123
|
+
): ControlCenterAccountService {
|
|
124
|
+
return createControlCenterAccount({
|
|
125
|
+
isCurrent: options.isCurrent ?? (() => true),
|
|
126
|
+
usage: options.usage ?? ((profile, signal) => requestProfileCodexUsage(profile, {
|
|
127
|
+
readCredentials: async name => readStoredProfileCodexCredential(await sharedAgentDir(process.env), name), signal,
|
|
128
|
+
})),
|
|
129
|
+
profiles: () => backend.profiles(), active: readActiveProfileName,
|
|
130
|
+
defaultProfile: () => backend.run(["account", "default"]),
|
|
131
|
+
switch: profile => backend.activate ? switchAccountInPlace(ctx, profile, async name => {
|
|
132
|
+
if (!(options.isCurrent?.() ?? true) || !ctx.isIdle()) throw new Error("Account action unavailable.");
|
|
133
|
+
await backend.activate!(name);
|
|
134
|
+
}, refreshUsage, requestRender) : Promise.resolve(false),
|
|
135
|
+
setDefault: async profile => {
|
|
136
|
+
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
137
|
+
if (!(options.isCurrent?.() ?? true) || !ctx.isIdle()) return false;
|
|
138
|
+
return (await backend.run(["account", "default", profile ?? "--clear"])).code === 0;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
9
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
10
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
11
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
12
|
+
}
|
|
13
|
+
return next(specifier, context);
|
|
14
|
+
} });
|
|
15
|
+
const { createControlCenterAgents } = await import("./control-center-agents.js");
|
|
16
|
+
|
|
17
|
+
function fixture() {
|
|
18
|
+
const home = mkdtempSync(join(tmpdir(), "osdy-cc08-"));
|
|
19
|
+
const agentDir = join(home, ".pi", "agent");
|
|
20
|
+
mkdirSync(agentDir, { recursive: true });
|
|
21
|
+
const path = join(agentDir, "settings.json");
|
|
22
|
+
writeFileSync(path, JSON.stringify({ packages: [{ source: "npm:gentle-pi", extensions: ["!unrelated.ts"], themes: [] }, "npm:unrelated"], unrelated: true }));
|
|
23
|
+
let installs = 0;
|
|
24
|
+
const options = { home, agentDir, cwd: home, env: {}, isIdle: () => true, install: () => {
|
|
25
|
+
installs++;
|
|
26
|
+
const settings = JSON.parse(readFileSync(path, "utf8")) as { packages: unknown[] };
|
|
27
|
+
settings.packages.push("npm:pi-subagents-j0k3r");
|
|
28
|
+
writeFileSync(path, JSON.stringify(settings));
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
} };
|
|
31
|
+
return { options, path, installs: () => installs };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void test("Agents read is inspection-only and describes the confirmed installation and real target", async () => {
|
|
35
|
+
const f = fixture(); const before = readFileSync(f.path, "utf8");
|
|
36
|
+
const service = createControlCenterAgents(f.options);
|
|
37
|
+
assert.equal(f.installs(), 0);
|
|
38
|
+
const view = await service.read(); await service.read();
|
|
39
|
+
assert.match(view.summary, /gentle.*Joker.*not installed/i);
|
|
40
|
+
assert.equal(view.rows.length, 2);
|
|
41
|
+
const details = view.rows[0]!.details!.join(" ");
|
|
42
|
+
assert.ok(details.includes(f.path));
|
|
43
|
+
assert.match(details, /Install npm:pi-subagents-j0k3r if absent/);
|
|
44
|
+
assert.match(details, /-extensions\/gentle-agents.ts/);
|
|
45
|
+
assert.match(details, /unrelated.*preserved/i);
|
|
46
|
+
assert.match(details, /reload.*restart/i);
|
|
47
|
+
assert.equal(f.installs(), 0); assert.equal(readFileSync(f.path, "utf8"), before);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
void test("confirmed Agents selection delegates Joker installation and owned Gentle/Joker filters", async () => {
|
|
51
|
+
const f = fixture(); const service = createControlCenterAgents(f.options);
|
|
52
|
+
assert.equal((await service.apply({ kind: "agents-provider", mode: "joker" })).kind, "reload");
|
|
53
|
+
assert.equal(f.installs(), 1);
|
|
54
|
+
assert.match(readFileSync(f.path, "utf8"), /-extensions\/gentle-agents.ts/);
|
|
55
|
+
assert.match((await service.read()).summary, /joker/i);
|
|
56
|
+
assert.equal((await service.apply({ kind: "agents-provider", mode: "gentle" })).kind, "reload");
|
|
57
|
+
const saved = JSON.parse(readFileSync(f.path, "utf8")) as { packages: unknown[]; unrelated: boolean; osdyPiJokerExclusionOwned: boolean };
|
|
58
|
+
assert.deepEqual(saved.packages[0], { source: "npm:gentle-pi", extensions: ["!unrelated.ts"], themes: [] });
|
|
59
|
+
assert.equal(saved.packages[1], "npm:unrelated");
|
|
60
|
+
assert.deepEqual(saved.packages[2], { source: "npm:pi-subagents-j0k3r", extensions: ["-./index.ts"] });
|
|
61
|
+
assert.equal(saved.unrelated, true); assert.equal(saved.osdyPiJokerExclusionOwned, true);
|
|
62
|
+
assert.equal(f.installs(), 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
void test("isolated Agents owner guard blocks rows, installs and writes even for a personal target", async () => {
|
|
66
|
+
for (const override of [true, false]) {
|
|
67
|
+
const f = fixture(); const before = readFileSync(f.path, "utf8");
|
|
68
|
+
const service = createControlCenterAgents({ ...f.options,
|
|
69
|
+
...(override ? { env: { PI_CODING_AGENT_DIR: f.options.agentDir } } : { agentDir: join(f.options.home, "isolated") }) });
|
|
70
|
+
const view = await service.read();
|
|
71
|
+
assert.deepEqual(view.rows, []); assert.match(view.note, /normal personal Pi.*isolated|override/i);
|
|
72
|
+
const result = await service.apply({ kind: "agents-provider", mode: "joker" });
|
|
73
|
+
assert.equal(result.kind, "rejected"); assert.equal(f.installs(), 0);
|
|
74
|
+
assert.equal(readFileSync(f.path, "utf8"), before);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
void test("Agents service guards idle, reentrancy and stale owner responses without reload", async () => {
|
|
79
|
+
const f = fixture(); let idle = false; let current = true; let writes = 0; let finish = () => {};
|
|
80
|
+
const service = createControlCenterAgents({ ...f.options, isIdle: () => idle, isCurrent: () => current,
|
|
81
|
+
setup: () => { writes++; return new Promise(resolve => { finish = () => resolve({ installed: false, changed: true, gentleCount: 1 }); }); } });
|
|
82
|
+
const action = { kind: "agents-provider", mode: "joker" } as const;
|
|
83
|
+
assert.equal((await service.apply(action)).kind, "rejected"); assert.equal(writes, 0);
|
|
84
|
+
idle = true; const pending = service.apply(action);
|
|
85
|
+
assert.equal((await service.apply(action)).kind, "rejected"); assert.equal(writes, 1);
|
|
86
|
+
current = false; finish(); assert.equal((await pending).kind, "rejected");
|
|
87
|
+
assert.equal((await service.apply(action)).kind, "rejected"); assert.equal(writes, 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
void test("Agents omits ineligible Gentle and ignores status from an old session", async () => {
|
|
91
|
+
const f = fixture(); writeFileSync(f.path, JSON.stringify({ packages: ["npm:pi-subagents-j0k3r"] }));
|
|
92
|
+
const service = createControlCenterAgents(f.options);
|
|
93
|
+
assert.deepEqual((await service.read()).rows.map(row => row.label), ["Joker agents"]);
|
|
94
|
+
const stale = createControlCenterAgents({ ...f.options, isCurrent: () => false });
|
|
95
|
+
assert.deepEqual((await stale.read()).rows, []);
|
|
96
|
+
const result = await service.apply({ kind: "agents-provider", mode: "gentle" });
|
|
97
|
+
assert.equal(result.kind, "rejected"); assert.match(result.message, /eligible personal Gentle/);
|
|
98
|
+
assert.equal(f.installs(), 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
void test("Agents owner errors retain honest failure and never request reload", async () => {
|
|
102
|
+
const f = fixture();
|
|
103
|
+
const service = createControlCenterAgents({ ...f.options, setup: () => Promise.reject(new Error("synthetic failure")) });
|
|
104
|
+
const result = await service.apply({ kind: "agents-provider", mode: "joker" });
|
|
105
|
+
assert.equal(result.kind, "rejected"); assert.match(result.message, /synthetic failure.*Reload not requested/);
|
|
106
|
+
assert.equal(f.installs(), 0);
|
|
107
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { inspectJokerAgents, setupJokerAgents, switchAgentMode } from "./agent-coexistence-setup.js";
|
|
3
|
+
import type { ControlCenterDetail, ProviderApplyResult } from "./control-center.js";
|
|
4
|
+
|
|
5
|
+
export type ControlCenterAgentsAction = { kind: "agents-provider"; mode: "joker" | "gentle" };
|
|
6
|
+
export interface ControlCenterAgentsService {
|
|
7
|
+
read(): Promise<ControlCenterDetail>;
|
|
8
|
+
apply(action: ControlCenterAgentsAction): Promise<ProviderApplyResult>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The coexistence owner validates normal personal Pi and owns every install/filter write. */
|
|
12
|
+
export function createControlCenterAgents(options: Parameters<typeof setupJokerAgents>[0] & {
|
|
13
|
+
isIdle: () => boolean;
|
|
14
|
+
isCurrent?: () => boolean;
|
|
15
|
+
inspect?: typeof inspectJokerAgents;
|
|
16
|
+
setup?: typeof setupJokerAgents;
|
|
17
|
+
switchMode?: typeof switchAgentMode;
|
|
18
|
+
}): ControlCenterAgentsService {
|
|
19
|
+
let busy = false;
|
|
20
|
+
const current = () => options.isCurrent?.() !== false;
|
|
21
|
+
const target = join(options.agentDir, "settings.json");
|
|
22
|
+
return {
|
|
23
|
+
async read() {
|
|
24
|
+
try {
|
|
25
|
+
const status = await (options.inspect ?? inspectJokerAgents)(options);
|
|
26
|
+
if (!current()) return { summary: "Agents status unavailable", note: "Session changed; reopen the panel.", rows: [] };
|
|
27
|
+
return {
|
|
28
|
+
summary: `Agent mode: ${status.mode} | Joker: ${status.jokerInstalled ? "installed" : "not installed"} | Gentle entries: ${status.gentleCount}`,
|
|
29
|
+
note: "Configured provider, not proof of loaded tools. Opening this category never installs or switches agents.",
|
|
30
|
+
rows: (["joker", "gentle"] as const).filter(mode => mode === "joker" || status.gentleCount > 0).map(mode => ({
|
|
31
|
+
label: mode === "joker" ? "Joker agents" : "Gentle agents", current: status.mode === mode,
|
|
32
|
+
action: { kind: "agents-provider", mode },
|
|
33
|
+
details: [`Target: ${target}`, mode === "joker"
|
|
34
|
+
? "Select Joker: Install npm:pi-subagents-j0k3r if absent; enable ./index.ts and exclude only -extensions/gentle-agents.ts from eligible Gentle entries."
|
|
35
|
+
: "Select Gentle: enable extensions/gentle-agents.ts; exclude Joker ./index.ts with the owner's -./index.ts filter. No installation.",
|
|
36
|
+
"Only owner-managed agent filters change; unrelated resources and installed packages are preserved.",
|
|
37
|
+
"Successful save closes this panel before resource reload; restart Pi if reload fails."],
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { summary: "Agents status unavailable", note: error instanceof Error ? error.message : "Inspection failed; reopen to retry.", rows: [] };
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async apply(action) {
|
|
45
|
+
if (busy || !current() || !options.isIdle())
|
|
46
|
+
return { kind: "rejected", message: "Agents selection requires the current idle session; nothing changed." };
|
|
47
|
+
busy = true;
|
|
48
|
+
try {
|
|
49
|
+
if (action.mode === "joker") await (options.setup ?? setupJokerAgents)(options);
|
|
50
|
+
else await (options.switchMode ?? switchAgentMode)({ ...options, mode: "off" });
|
|
51
|
+
if (!current()) return { kind: "rejected", message: "Session changed after the owner operation; reload not requested. Reopen status in the current session." };
|
|
52
|
+
return { kind: "reload", message: "Agents selection saved. Reloading resources; restart Pi if reload fails." };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { kind: "rejected", message: `Agents selection failed: ${error instanceof Error ? error.message : "unknown error"}. Reload not requested.` };
|
|
55
|
+
} finally {
|
|
56
|
+
busy = false;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
7
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
8
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
9
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
10
|
+
}
|
|
11
|
+
return nextResolve(specifier, context);
|
|
12
|
+
} });
|
|
13
|
+
const { createControlCenterGit } = await import("./control-center-git.js");
|
|
14
|
+
void test("Git reads branch/status only and delegates persisted enabled changes to its shared owner", async () => {
|
|
15
|
+
let enabled = true;
|
|
16
|
+
const calls: string[][] = [];
|
|
17
|
+
const applied: boolean[] = [];
|
|
18
|
+
const service = createControlCenterGit({
|
|
19
|
+
snapshot: () => ({ enabled, placement: "aboveEditor" }),
|
|
20
|
+
exec: (args) => {
|
|
21
|
+
calls.push(args);
|
|
22
|
+
if (args.includes("branch")) return Promise.resolve("feature\n");
|
|
23
|
+
if (args.includes("status")) return Promise.resolve(" M file.ts\n?? new.ts\n");
|
|
24
|
+
throw new Error("Unexpected Git inspection command");
|
|
25
|
+
},
|
|
26
|
+
applyEnabled: (value) => { enabled = value; applied.push(value); return Promise.resolve(true); },
|
|
27
|
+
});
|
|
28
|
+
const view = await service.read();
|
|
29
|
+
assert.match(view.summary, /Branch: feature.*2 changed/);
|
|
30
|
+
assert.match(view.note, /aboveEditor.*session.*read-only/i);
|
|
31
|
+
assert.deepEqual(calls, [
|
|
32
|
+
["--no-optional-locks", "branch", "--show-current"],
|
|
33
|
+
["--no-optional-locks", "status", "--short", "--untracked-files=normal"],
|
|
34
|
+
]);
|
|
35
|
+
assert.deepEqual(applied, []);
|
|
36
|
+
assert.equal((await service.apply({ kind: "git-enabled", value: false })).failed, false);
|
|
37
|
+
assert.deepEqual(applied, [false]);
|
|
38
|
+
assert.equal((await service.read()).rows[1]?.current, true);
|
|
39
|
+
});
|
|
40
|
+
void test("Git unavailable and failed persistence are honest, without new workflows", async () => {
|
|
41
|
+
const service = createControlCenterGit({ snapshot: () => ({ enabled: false, placement: "belowEditor" }),
|
|
42
|
+
exec: () => Promise.reject(new Error("not a repository")), applyEnabled: () => Promise.resolve(false) });
|
|
43
|
+
assert.match((await service.read()).summary, /unavailable/);
|
|
44
|
+
assert.match((await service.apply({ kind: "git-enabled", value: true })).message, /Applied live.*could not be saved/);
|
|
45
|
+
const detached = createControlCenterGit({ snapshot: () => ({ enabled: true, placement: "aboveEditor" }),
|
|
46
|
+
exec: () => Promise.resolve(""), applyEnabled: () => Promise.resolve(true) });
|
|
47
|
+
assert.match((await detached.read()).summary, /detached.*clean/);
|
|
48
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ControlCenterService } from "./control-center.js";
|
|
2
|
+
import type { WorkingTreePlacement } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface GitDependencies {
|
|
5
|
+
snapshot(): { enabled: boolean; placement: WorkingTreePlacement };
|
|
6
|
+
exec(args: string[]): Promise<string>;
|
|
7
|
+
applyEnabled(value: boolean): Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Read-only Git inspection; the runtime retains ownership of widget persistence. */
|
|
11
|
+
export function createControlCenterGit(dependencies: GitDependencies): ControlCenterService {
|
|
12
|
+
return {
|
|
13
|
+
async read() {
|
|
14
|
+
let summary: string;
|
|
15
|
+
try {
|
|
16
|
+
const [branch, status] = await Promise.all([
|
|
17
|
+
dependencies.exec(["--no-optional-locks", "branch", "--show-current"]),
|
|
18
|
+
dependencies.exec(["--no-optional-locks", "status", "--short", "--untracked-files=normal"]),
|
|
19
|
+
]);
|
|
20
|
+
const changed = status.split("\n").filter((line) => line.trim()).length;
|
|
21
|
+
summary = `Branch: ${branch.trim() || "detached HEAD"} | ${changed ? `${changed} changed entries` : "working tree clean"}`;
|
|
22
|
+
} catch {
|
|
23
|
+
summary = "Git branch/worktree unavailable (not a repository or inspection failed).";
|
|
24
|
+
}
|
|
25
|
+
const state = dependencies.snapshot();
|
|
26
|
+
return {
|
|
27
|
+
summary,
|
|
28
|
+
note: `Position: ${state.placement} (session detail, read-only). Inspection never writes Git.`,
|
|
29
|
+
rows: [true, false].map((value) => ({ label: `Working-tree widget: ${value ? "enabled" : "disabled"}`,
|
|
30
|
+
current: state.enabled === value, action: { kind: "git-enabled" as const, value } })),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
async apply(action) {
|
|
34
|
+
if (action.kind !== "git-enabled") return { failed: true, message: "Not a Git preference action" };
|
|
35
|
+
const saved = await dependencies.applyEnabled(action.value);
|
|
36
|
+
return { failed: !saved, message: saved ? "Saved globally: working-tree widget"
|
|
37
|
+
: "Applied live: working-tree widget, but could not be saved. Retry to persist." };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|