a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
/**
|
||
* @file pets.js
|
||
* @description Custom pets for the dashboard mascot (Sagi). A pet is a folder
|
||
* `<data dir>/pets/<id>/` holding `meta.json` (written here) and either a
|
||
* Codex pet's `spritesheet.webp` — the `.codex-pet.zip` format: an 8×9 grid of
|
||
* 192×208 frames, one row per animation state (v2 adds two rows of gaze
|
||
* poses: 8×11) — or one uploaded image. Pets
|
||
* come from exactly three places: an uploaded `.codex-pet.zip`, an uploaded
|
||
* image, or one of the FEATURED pets, downloaded from codex-pets.net by a fixed
|
||
* id — never a URL or a path from a request. None is bundled with AgentHub:
|
||
* the featured pets are other people's uploads with no redistribution
|
||
* licence, so each install fetches its own copy from the site that hosts
|
||
* them. An id is always made here; SVG is refused, because an SVG served from
|
||
* the dashboard's origin runs script when opened.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
"use strict";
|
||
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
const crypto = require("node:crypto");
|
||
const AdmZip = require("adm-zip");
|
||
const { getDataDir } = require("./claude-home");
|
||
|
||
const MAX_BUNDLE_BYTES = 8 * 1024 * 1024;
|
||
const MAX_SHEET_BYTES = 6 * 1024 * 1024;
|
||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||
const MAX_MANIFEST_BYTES = 16 * 1024;
|
||
const MAX_PETS = 50;
|
||
/** The Codex atlas: 8 columns × 9 rows of 192×208 frames. */
|
||
const SHEET = { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 };
|
||
/** A v2 atlas (`spriteVersionNumber: 2`): the same plus rows 9–10, 16 gaze poses. */
|
||
const SHEET_V2_HEIGHT = 2288;
|
||
const FEATURED_HOST = "https://codex-pets.net";
|
||
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
|
||
const FEATURED = [
|
||
{ id: "goku", displayName: "Goku", by: "hackminit" },
|
||
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
|
||
{ id: "11", displayName: "11", by: "ct" },
|
||
];
|
||
const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/;
|
||
const IMAGE_TYPES = {
|
||
png: "image/png",
|
||
jpg: "image/jpeg",
|
||
gif: "image/gif",
|
||
webp: "image/webp",
|
||
};
|
||
|
||
let fetchImpl = (...args) => fetch(...args);
|
||
/** Test seam: replace the HTTP client the featured install uses. */
|
||
function __setFetch(fn) {
|
||
fetchImpl = fn || ((...args) => fetch(...args));
|
||
}
|
||
|
||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||
|
||
function petsRoot() {
|
||
return path.join(getDataDir(), "pets");
|
||
}
|
||
|
||
function petDir(id) {
|
||
if (!ID_RE.test(String(id))) throw fail("ENOPET", "pet not found");
|
||
return path.join(petsRoot(), id);
|
||
}
|
||
|
||
/** Width and height from a WebP header (VP8X, VP8 or VP8L), else null. */
|
||
function webpSize(buf) {
|
||
if (buf.length < 30) return null;
|
||
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP")
|
||
return null;
|
||
const chunk = buf.toString("ascii", 12, 16);
|
||
if (chunk === "VP8X")
|
||
return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
|
||
if (chunk === "VP8 ")
|
||
return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
||
if (chunk === "VP8L") {
|
||
const bits = buf.readUInt32LE(21);
|
||
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The raster type of an uploaded image by its magic bytes, else null. */
|
||
function sniffImage(buf) {
|
||
if (
|
||
buf.length >= 8 &&
|
||
buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||
)
|
||
return "png";
|
||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
|
||
if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.toString("ascii", 0, 6))) return "gif";
|
||
if (
|
||
webpSize(buf) ||
|
||
(buf.length >= 12 &&
|
||
buf.toString("ascii", 8, 12) === "WEBP" &&
|
||
buf.toString("ascii", 0, 4) === "RIFF")
|
||
)
|
||
return "webp";
|
||
return null;
|
||
}
|
||
|
||
/** A display string from untrusted input: trimmed, single-line, capped. */
|
||
function cleanText(value, max) {
|
||
return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, max) : "";
|
||
}
|
||
|
||
function checkSheet(buf) {
|
||
if (buf.length > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB");
|
||
const size = webpSize(buf);
|
||
if (!size) throw fail("EBADPET", "the spritesheet is not a WebP image");
|
||
if (
|
||
size.width !== SHEET.width ||
|
||
(size.height !== SHEET.height && size.height !== SHEET_V2_HEIGHT)
|
||
)
|
||
throw fail(
|
||
"EBADPET",
|
||
`the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height} (v2: ${SHEET.width}×${SHEET_V2_HEIGHT})`
|
||
);
|
||
}
|
||
|
||
function readMeta(id) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(path.join(petDir(id), "meta.json"), "utf8"));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function view(id, meta) {
|
||
return {
|
||
id,
|
||
displayName: meta.displayName,
|
||
description: meta.description || "",
|
||
kind: meta.kind,
|
||
source: meta.source,
|
||
...(meta.featuredId ? { featuredId: meta.featuredId } : {}),
|
||
...(meta.by ? { by: meta.by } : {}),
|
||
};
|
||
}
|
||
|
||
function listPets() {
|
||
let names = [];
|
||
try {
|
||
names = fs.readdirSync(petsRoot());
|
||
} catch {
|
||
return [];
|
||
}
|
||
return names
|
||
.filter((n) => ID_RE.test(n))
|
||
.map((n) => [n, readMeta(n)])
|
||
.filter(([, meta]) => meta && (meta.kind === "codex" || meta.kind === "image"))
|
||
.sort((a, b) => String(a[1].addedAt).localeCompare(String(b[1].addedAt)))
|
||
.map(([n, meta]) => view(n, meta));
|
||
}
|
||
|
||
/** Writes a pet atomically: a temp folder renamed into place. */
|
||
function store(id, meta, file, body) {
|
||
const existing = listPets();
|
||
if (!existing.some((p) => p.id === id) && existing.length >= MAX_PETS)
|
||
throw fail("ETOOMANY", `at most ${MAX_PETS} pets`);
|
||
const root = petsRoot();
|
||
fs.mkdirSync(root, { recursive: true });
|
||
const tmp = fs.mkdtempSync(path.join(root, ".new-"));
|
||
try {
|
||
fs.writeFileSync(path.join(tmp, file), body);
|
||
fs.writeFileSync(
|
||
path.join(tmp, "meta.json"),
|
||
JSON.stringify({ ...meta, file, addedAt: new Date().toISOString() })
|
||
);
|
||
fs.rmSync(petDir(id), { recursive: true, force: true });
|
||
fs.renameSync(tmp, petDir(id));
|
||
} catch (err) {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
throw err;
|
||
}
|
||
return view(id, readMeta(id));
|
||
}
|
||
|
||
const newId = () => `p-${crypto.randomBytes(6).toString("hex")}`;
|
||
|
||
/** Imports a `.codex-pet.zip`: `pet.json` + `spritesheet.webp`, at the root
|
||
* or in one top-level folder. Nothing else in the archive is read. */
|
||
function importBundle(buf) {
|
||
if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file");
|
||
if (buf.length > MAX_BUNDLE_BYTES) throw fail("ETOOBIG", "the bundle is larger than 8 MB");
|
||
let entries;
|
||
try {
|
||
entries = new AdmZip(buf).getEntries();
|
||
} catch {
|
||
throw fail("EBADPET", "not a zip archive");
|
||
}
|
||
const pick = (name) =>
|
||
entries.find(
|
||
(e) =>
|
||
!e.isDirectory &&
|
||
/^(?:[^/]+\/)?[^/]+$/.test(e.entryName) &&
|
||
path.posix.basename(e.entryName) === name
|
||
);
|
||
const manifest = pick("pet.json");
|
||
const sheet = pick("spritesheet.webp");
|
||
if (!manifest || !sheet)
|
||
throw fail("EBADPET", "a .codex-pet bundle holds pet.json and spritesheet.webp");
|
||
// Sizes from the central directory, checked BEFORE inflating (zip bombs).
|
||
if (manifest.header.size > MAX_MANIFEST_BYTES) throw fail("EBADPET", "pet.json is too large");
|
||
if (sheet.header.size > MAX_SHEET_BYTES)
|
||
throw fail("ETOOBIG", "the spritesheet is larger than 6 MB");
|
||
let pet;
|
||
try {
|
||
pet = JSON.parse(manifest.getData().toString("utf8"));
|
||
} catch {
|
||
throw fail("EBADPET", "pet.json is not valid JSON");
|
||
}
|
||
const body = sheet.getData();
|
||
checkSheet(body);
|
||
const displayName =
|
||
cleanText(pet && pet.displayName, 60) || cleanText(pet && pet.id, 60) || "Pet";
|
||
return store(
|
||
newId(),
|
||
{
|
||
kind: "codex",
|
||
source: "upload",
|
||
displayName,
|
||
description: cleanText(pet && pet.description, 300),
|
||
},
|
||
"spritesheet.webp",
|
||
body
|
||
);
|
||
}
|
||
|
||
/** Adds one uploaded image (PNG, JPEG, GIF or WebP) as a still pet. */
|
||
function addImage(buf, name) {
|
||
if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file");
|
||
if (buf.length > MAX_IMAGE_BYTES) throw fail("ETOOBIG", "the image is larger than 4 MB");
|
||
const ext = sniffImage(buf);
|
||
if (!ext) throw fail("EBADPET", "only PNG, JPEG, GIF or WebP images");
|
||
const displayName = cleanText(String(name || "").replace(/\.[^.]+$/, ""), 60) || "Pet";
|
||
return store(
|
||
newId(),
|
||
{ kind: "image", source: "upload", displayName, type: ext },
|
||
`image.${ext}`,
|
||
buf
|
||
);
|
||
}
|
||
|
||
async function fetchCapped(url, max) {
|
||
const res = await fetchImpl(url, { signal: AbortSignal.timeout(20_000), redirect: "error" });
|
||
if (!res.ok) throw fail("EFETCH", `codex-pets.net answered ${res.status}`);
|
||
const len = Number(res.headers?.get?.("content-length"));
|
||
if (len > max) throw fail("ETOOBIG", "the download is too large");
|
||
const body = Buffer.from(await res.arrayBuffer());
|
||
if (body.length > max) throw fail("ETOOBIG", "the download is too large");
|
||
return body;
|
||
}
|
||
|
||
/** Downloads one FEATURED pet from codex-pets.net into this install. */
|
||
async function installFeatured(featuredId) {
|
||
const f = FEATURED.find((x) => x.id === featuredId);
|
||
if (!f) throw fail("ENOPET", "not a featured pet");
|
||
const base = `${FEATURED_HOST}/api/pets/${encodeURIComponent(f.id)}`;
|
||
let description = "";
|
||
try {
|
||
const meta = JSON.parse((await fetchCapped(base, MAX_MANIFEST_BYTES * 4)).toString("utf8"));
|
||
description = cleanText(meta && meta.pet && meta.pet.description, 300);
|
||
} catch (err) {
|
||
if (err.code === "ETOOBIG") throw err;
|
||
/* the description is optional; the spritesheet is not */
|
||
}
|
||
let body;
|
||
try {
|
||
body = await fetchCapped(`${base}/spritesheet`, MAX_SHEET_BYTES);
|
||
} catch (err) {
|
||
if (err.code) throw err;
|
||
throw fail("EFETCH", `could not reach codex-pets.net: ${err.message}`);
|
||
}
|
||
checkSheet(body);
|
||
return store(
|
||
`cp-${f.id}`,
|
||
{
|
||
kind: "codex",
|
||
source: "featured",
|
||
featuredId: f.id,
|
||
by: f.by,
|
||
displayName: f.displayName,
|
||
description,
|
||
},
|
||
"spritesheet.webp",
|
||
body
|
||
);
|
||
}
|
||
|
||
function removePet(id) {
|
||
const dir = petDir(id);
|
||
if (!readMeta(id)) throw fail("ENOPET", "pet not found");
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
|
||
/** The file to serve for a pet and its content type. */
|
||
function spriteFile(id) {
|
||
const meta = readMeta(id);
|
||
if (!meta || (meta.file !== "spritesheet.webp" && !/^image\.(png|jpg|gif|webp)$/.test(meta.file)))
|
||
throw fail("ENOPET", "pet not found");
|
||
const ext = meta.file.split(".").pop();
|
||
return { path: path.join(petDir(id), meta.file), type: IMAGE_TYPES[ext] };
|
||
}
|
||
|
||
function featured() {
|
||
const installed = new Set(
|
||
listPets()
|
||
.map((p) => p.featuredId)
|
||
.filter(Boolean)
|
||
);
|
||
return FEATURED.map((f) => ({
|
||
...f,
|
||
installed: installed.has(f.id),
|
||
page: `${FEATURED_HOST}/#/pets/${f.id}`,
|
||
}));
|
||
}
|
||
|
||
module.exports = {
|
||
SHEET,
|
||
FEATURED,
|
||
MAX_BUNDLE_BYTES,
|
||
MAX_IMAGE_BYTES,
|
||
listPets,
|
||
featured,
|
||
importBundle,
|
||
addImage,
|
||
installFeatured,
|
||
removePet,
|
||
spriteFile,
|
||
webpSize,
|
||
__setFetch,
|
||
};
|