+62
-62
@@ -1,4 +1,5 @@
|
||||
import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
|
||||
import type { DeepMutable } from "@opencode/plugin/promise/types";
|
||||
import type { ModelInfo } from "@opencode/client";
|
||||
import { z } from "zod";
|
||||
import { settings, workerNames } from "./config.js";
|
||||
@@ -7,18 +8,38 @@ import { discover, modelKey } from "./provider.js";
|
||||
import { descriptions, routingGuide } from "./routing.js";
|
||||
import { Skill } from "@opencode/schema/skill";
|
||||
import { AbsolutePath } from "@opencode/schema/schema";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import antiOverengineering from "../skills/anti-overengineering.md" with { type: "text" };
|
||||
import taskPlanning from "../skills/task-planning.md" with { type: "text" };
|
||||
import { AgentsRpc } from "./agents-rpc.js";
|
||||
import { matches } from "./wildcard.js";
|
||||
|
||||
// Permission actions use wildcards, not filesystem globs.
|
||||
function matches(value: string, pattern: string) {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "s").test(value);
|
||||
// Assignments use "" as unset.
|
||||
function assigned(value: string | undefined): value is string {
|
||||
return value !== undefined && value !== "";
|
||||
}
|
||||
|
||||
/** Points the agent at the available model, using the variant or a reasoning-effort fallback. */
|
||||
function useModel(
|
||||
agent: DeepMutable<Agent.Info>,
|
||||
assignment: ReturnType<typeof model>,
|
||||
available: ModelInfo,
|
||||
) {
|
||||
const hasVariant =
|
||||
assignment.variant !== undefined &&
|
||||
available.variants.some(({ id }) => id === assignment.variant);
|
||||
agent.model = {
|
||||
providerID: Provider.ID.make(available.providerID),
|
||||
id: Model.ID.make(available.id),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.variant);
|
||||
agent.request = {
|
||||
settings:
|
||||
hasVariant || assignment.variant === undefined
|
||||
? {}
|
||||
: { reasoningEffort: assignment.variant },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
}
|
||||
|
||||
const reading = [
|
||||
@@ -40,12 +61,12 @@ export default Plugin.define({
|
||||
id: "stanp-opencode.agents",
|
||||
async setup(context) {
|
||||
const { agents } = settings.parse(context.options);
|
||||
if (!workerNames.some((name) => agents?.[name])) return;
|
||||
if (!workerNames.some((name) => assigned(agents?.[name]))) return;
|
||||
const assignments: Partial<Record<(typeof workerNames)[number], ReturnType<typeof model>>> =
|
||||
{};
|
||||
for (const name of workerNames) {
|
||||
const reference = agents?.[name];
|
||||
if (reference) assignments[name] = model(reference, `agents ${name}`);
|
||||
if (assigned(reference)) assignments[name] = model(reference, `agents ${name}`);
|
||||
else if (!name.startsWith("deep-")) throw new Error(`agents ${name} is required`);
|
||||
}
|
||||
let models = new Map<string, ModelInfo>();
|
||||
@@ -106,13 +127,14 @@ export default Plugin.define({
|
||||
["task-planning", taskPlanning],
|
||||
] as const) {
|
||||
if (editor.get(name)) continue;
|
||||
const description = content.match(/^description: "?(.+?)"?$/m)?.[1];
|
||||
if (!description) throw new Error(`${name} skill has no description`);
|
||||
const description = content.match(/^description: "?(.+?)"?$/mu)?.[1];
|
||||
if (description === undefined || description === "")
|
||||
throw new Error(`${name} skill has no description`);
|
||||
editor.add({
|
||||
id: Skill.ID.make(name),
|
||||
name: Skill.Name.make(name),
|
||||
description,
|
||||
path: AbsolutePath.make(fileURLToPath(import.meta.url)),
|
||||
path: AbsolutePath.make(import.meta.filename),
|
||||
content,
|
||||
});
|
||||
}
|
||||
@@ -123,22 +145,20 @@ export default Plugin.define({
|
||||
.list()
|
||||
.map((tool) => tool.id)
|
||||
.filter((id) => id.startsWith("codegraph_"))
|
||||
.sort();
|
||||
.toSorted();
|
||||
if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return;
|
||||
codegraphTools = next;
|
||||
if (agentTransformReady)
|
||||
queueMicrotask(() => {
|
||||
void context.agent
|
||||
.reload()
|
||||
.catch((error) =>
|
||||
console.error("stanp-opencode agent reload failed", error),
|
||||
);
|
||||
void context.agent.reload().catch((error) => {
|
||||
console.error("stanp-opencode agent reload failed", error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const availableFor = (assignment: { providerID: string; id: string }) =>
|
||||
models.get(modelKey(assignment.providerID, assignment.id));
|
||||
await context.agent.transform((editor) => {
|
||||
const availableFor = (assignment: { providerID: string; id: string }) =>
|
||||
models.get(modelKey(assignment.providerID, assignment.id));
|
||||
const deepLo = assignments["deep-lo"];
|
||||
const deep =
|
||||
deepLo !== undefined &&
|
||||
@@ -154,59 +174,37 @@ export default Plugin.define({
|
||||
active.clear();
|
||||
for (const name of workers) active.add(name);
|
||||
if (workers.length === 0 || coordinate === undefined) return;
|
||||
for (const name of workers) {
|
||||
const assignment = assignments[name]!;
|
||||
const available = availableFor(assignment)!;
|
||||
const configureWorker = (
|
||||
name: (typeof workerNames)[number],
|
||||
assignment: ReturnType<typeof model>,
|
||||
available: ModelInfo,
|
||||
) => {
|
||||
editor.update(name, (agent) => {
|
||||
const hasVariant =
|
||||
assignment.variant !== undefined &&
|
||||
available.variants.some(({ id }) => id === assignment.variant);
|
||||
agent.name = Agent.Name.make(name);
|
||||
agent.mode = "subagent";
|
||||
agent.hidden = false;
|
||||
agent.description = descriptions[name];
|
||||
agent.model = {
|
||||
providerID: Provider.ID.make(available.providerID),
|
||||
id: Model.ID.make(available.id),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.variant!);
|
||||
agent.request = {
|
||||
settings:
|
||||
hasVariant || assignment.variant === undefined
|
||||
? {}
|
||||
: { reasoningEffort: assignment.variant },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
useModel(agent, assignment, available);
|
||||
agent.permissions = [
|
||||
...structuredClone(inherited),
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
];
|
||||
});
|
||||
};
|
||||
for (const name of workers) {
|
||||
const assignment = assignments[name];
|
||||
const available = assignment && availableFor(assignment);
|
||||
if (assignment && available) configureWorker(name, assignment, available);
|
||||
}
|
||||
const available = availableFor(coordinate);
|
||||
if (!available) return;
|
||||
editor.update("coordinate", (agent) => {
|
||||
const available = availableFor(coordinate)!;
|
||||
const hasVariant =
|
||||
coordinate.variant !== undefined &&
|
||||
available.variants.some(({ id }) => id === coordinate.variant);
|
||||
agent.name = Agent.Name.make("coordinate");
|
||||
agent.mode = "primary";
|
||||
agent.hidden = false;
|
||||
agent.description =
|
||||
"Read, plan, and coordinate specialist workers; never implement directly.";
|
||||
agent.model = {
|
||||
providerID: Provider.ID.make(available.providerID),
|
||||
id: Model.ID.make(available.id),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.variant!);
|
||||
agent.request = {
|
||||
settings:
|
||||
hasVariant || coordinate.variant === undefined
|
||||
? {}
|
||||
: { reasoningEffort: coordinate.variant },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
useModel(agent, coordinate, available);
|
||||
agent.system = routingGuide(deep);
|
||||
agent.permissions = [
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
@@ -242,18 +240,20 @@ export default Plugin.define({
|
||||
}
|
||||
await context.session.hook("context", (event) => {
|
||||
if (workerNames.some((name) => name === event.agent)) {
|
||||
delete event.tools.subagent;
|
||||
delete event.tools["subagent"];
|
||||
return;
|
||||
}
|
||||
const tool = event.tools.subagent;
|
||||
const tool = event.tools["subagent"];
|
||||
if (event.agent === "coordinate" && workers.length === 0) {
|
||||
delete event.tools.subagent;
|
||||
delete event.tools["subagent"];
|
||||
return;
|
||||
}
|
||||
if (event.agent !== "coordinate" || !tool) return;
|
||||
tool.description = `Delegate a self-contained assignment to one of these workers:\n${workers.map((name) => `- ${name}: ${descriptions[name]}`).join("\n")}`;
|
||||
// The executable definition remains native; constrain only this request's schema.
|
||||
const properties = z.record(z.string(), z.unknown()).parse(tool.input.properties ?? {});
|
||||
const properties = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(tool.input["properties"] ?? {});
|
||||
tool.input = {
|
||||
...tool.input,
|
||||
properties: { ...properties, agent: { type: "string", enum: workers } },
|
||||
@@ -273,7 +273,7 @@ export default Plugin.define({
|
||||
// Commands can create child sessions without going through the subagent tool.
|
||||
await context.session.hook("prompt", async (event) => {
|
||||
const child = await context.session.get({ sessionID: event.sessionID });
|
||||
if (!child.parentID) return;
|
||||
if (child.parentID === undefined || child.parentID === "") return;
|
||||
const parent = await context.session.get({ sessionID: child.parentID });
|
||||
if (child.agent === "coordinate" && parent.agent !== "multi-work")
|
||||
throw new Error("coordinate cannot run as a subagent without Multi-work");
|
||||
|
||||
+3
-1
@@ -5,5 +5,7 @@ export function model(reference: string, name: string) {
|
||||
if (slash <= 0) throw new Error(`${name} must be provider/model[#variant]: ${reference}`);
|
||||
const providerID = path.slice(0, slash);
|
||||
const id = path.slice(slash + 1);
|
||||
return variant ? { providerID, id, variant } : { providerID, id };
|
||||
return variant !== undefined && variant !== ""
|
||||
? { providerID, id, variant }
|
||||
: { providerID, id };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Permission actions use wildcards, not filesystem globs.
|
||||
export function matches(value: string, pattern: string) {
|
||||
const escaped = pattern
|
||||
.replaceAll(/[.+^${}()|[\]\\]/gu, "\\$&")
|
||||
.replaceAll("*", ".*")
|
||||
.replaceAll("?", ".");
|
||||
return new RegExp(`^${escaped}$`, "su").test(value);
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import { realpath } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function command(args: string[], cwd: string, signal: AbortSignal) {
|
||||
const [file, ...rest] = args;
|
||||
if (file === undefined) throw new Error("Missing command");
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
args[0]!,
|
||||
args.slice(1),
|
||||
file,
|
||||
rest,
|
||||
{ cwd, signal, timeout: 30_000, maxBuffer: 2_000_000 },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) reject(new Error(`${args[0]} failed: ${stderr || error.message}`));
|
||||
if (error) reject(new Error(`${file} failed: ${stderr || error.message}`));
|
||||
else resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from "zod";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Herdr, rootSchema } from "./herdr.js";
|
||||
import { multiWorkGuide } from "../agents/routing.js";
|
||||
import { matches } from "../agents/wildcard.js";
|
||||
|
||||
export const bindingSchema = z
|
||||
.object({
|
||||
@@ -62,14 +63,8 @@ const readTools = new Set([
|
||||
export const childProtocol =
|
||||
"Bound child sessions use stanp-opencode multi-work protocol 1 and must remain in their assigned worktree.";
|
||||
|
||||
function matches(value: string, pattern: string) {
|
||||
return new RegExp(
|
||||
`^${pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".")}$`,
|
||||
"s",
|
||||
).test(value);
|
||||
function present(value: string | undefined): value is string {
|
||||
return value !== undefined && value !== "";
|
||||
}
|
||||
|
||||
export const installMultiWork = (
|
||||
@@ -158,6 +153,20 @@ export const installMultiWork = (
|
||||
return binding;
|
||||
});
|
||||
|
||||
const verifyLocalChild = (
|
||||
sessionID: Session.ID,
|
||||
parent: { id: string; location: { directory: string } },
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const child = yield* context.session.get({ sessionID });
|
||||
if (
|
||||
child.parentID !== parent.id ||
|
||||
child.agent !== "fast-lo" ||
|
||||
child.location.directory !== parent.location.directory
|
||||
)
|
||||
throw new Error("Local fast-lo child binding changed");
|
||||
});
|
||||
|
||||
// Commands can create children without going through the subagent tool.
|
||||
yield* context.session.hook("prompt", (event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -192,12 +201,12 @@ export const installMultiWork = (
|
||||
if (event.agent === "coordinate" && session.parentID)
|
||||
yield* validate(event.sessionID);
|
||||
if (event.agent !== "multi-work") {
|
||||
const tool = event.tools.subagent;
|
||||
const tool = event.tools["subagent"];
|
||||
if (tool) {
|
||||
const properties = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(tool.input.properties ?? {});
|
||||
delete properties.worktree;
|
||||
.parse(tool.input["properties"] ?? {});
|
||||
delete properties["worktree"];
|
||||
tool.input = { ...tool.input, properties };
|
||||
}
|
||||
return;
|
||||
@@ -211,13 +220,13 @@ export const installMultiWork = (
|
||||
);
|
||||
if (!root)
|
||||
throw new Error("multi-work requires a live, unambiguous Herdr root checkout");
|
||||
const tool = event.tools.subagent;
|
||||
const tool = event.tools["subagent"];
|
||||
if (tool) {
|
||||
tool.input = z.toJSONSchema(inputSchema);
|
||||
const properties = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(tool.input.properties);
|
||||
tool.input.properties = {
|
||||
.parse(tool.input["properties"]);
|
||||
tool.input["properties"] = {
|
||||
...properties,
|
||||
agent: { type: "string", enum: ["fast-lo", "coordinate"] },
|
||||
};
|
||||
@@ -238,7 +247,7 @@ export const installMultiWork = (
|
||||
if (input.agent === "multi-work")
|
||||
throw new Error("multi-work is primary only");
|
||||
if (
|
||||
input.sessionID &&
|
||||
present(input.sessionID) &&
|
||||
(yield* context.storage.get(bindingKey(input.sessionID))) !==
|
||||
undefined &&
|
||||
(call.agent !== "multi-work" || input.agent !== "coordinate")
|
||||
@@ -261,22 +270,13 @@ export const installMultiWork = (
|
||||
);
|
||||
if (input.agent === "fast-lo") {
|
||||
if (input.worktree) throw new Error("fast-lo is local retrieval only");
|
||||
if (input.sessionID) {
|
||||
const child = yield* context.session.get({
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
});
|
||||
if (
|
||||
child.parentID !== parent.id ||
|
||||
child.agent !== "fast-lo" ||
|
||||
child.location.directory !== parent.location.directory
|
||||
)
|
||||
throw new Error("Local fast-lo child binding changed");
|
||||
}
|
||||
if (present(input.sessionID))
|
||||
yield* verifyLocalChild(Session.ID.make(input.sessionID), parent);
|
||||
return yield* native.execute(input, call);
|
||||
}
|
||||
if (input.agent !== "coordinate")
|
||||
throw new Error("multi-work delegates only to fast-lo or coordinate");
|
||||
if (input.sessionID && input.worktree !== undefined)
|
||||
if (present(input.sessionID) && input.worktree !== undefined)
|
||||
throw new Error(
|
||||
"Coordinate resumes require only sessionID; worktree is create-only",
|
||||
);
|
||||
@@ -287,7 +287,7 @@ export const installMultiWork = (
|
||||
return yield* Effect.gen(function* () {
|
||||
let initialized = false;
|
||||
let placement: z.infer<typeof bindingSchema> | undefined;
|
||||
if (input.sessionID) {
|
||||
if (present(input.sessionID)) {
|
||||
const binding = yield* validate(Session.ID.make(input.sessionID));
|
||||
if (binding.owner !== call.sessionID)
|
||||
throw new Error("Cannot resume another parent's binding");
|
||||
@@ -336,7 +336,7 @@ export const installMultiWork = (
|
||||
}
|
||||
const { worktree, ...args } = input;
|
||||
const callKey = `multi-work-call:${call.sessionID}:${call.id}`;
|
||||
if (!input.sessionID) {
|
||||
if (!present(input.sessionID)) {
|
||||
const previous = yield* context.storage.get(callKey);
|
||||
if (previous !== undefined)
|
||||
throw new Error(
|
||||
@@ -350,7 +350,7 @@ export const installMultiWork = (
|
||||
// Native 2.0.22 awaits this update after creation and before prompt admission.
|
||||
yield* call.progress(metadata);
|
||||
if (initialized) return;
|
||||
const id = z.string().parse(metadata.sessionID);
|
||||
const id = z.string().parse(metadata["sessionID"]);
|
||||
if (busy.has(id))
|
||||
throw new Error(
|
||||
"This child is already being delegated",
|
||||
@@ -433,7 +433,7 @@ export const installMultiWork = (
|
||||
coordinator?.mode === "all" &&
|
||||
coordinator.system?.includes(
|
||||
childProtocol,
|
||||
) &&
|
||||
) === true &&
|
||||
[
|
||||
"coordinate",
|
||||
"fast-lo",
|
||||
@@ -503,7 +503,7 @@ export const installMultiWork = (
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
busy.delete(lock);
|
||||
if (childLock) busy.delete(childLock);
|
||||
if (present(childLock)) busy.delete(childLock);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
+38
-25
@@ -11,11 +11,15 @@ const TIMEOUT_MS = 5_000;
|
||||
const RETRY_MS = 2_000;
|
||||
const RETRIES = 4;
|
||||
const WEEK = 604_800;
|
||||
const jsonValue = z.json();
|
||||
|
||||
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export function claudeCredentialsPath() {
|
||||
return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), ".credentials.json");
|
||||
function claudeCredentialsPath() {
|
||||
return join(
|
||||
process.env["CLAUDE_CONFIG_DIR"] ?? join(homedir(), ".claude"),
|
||||
".credentials.json",
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = z.object({
|
||||
@@ -27,7 +31,7 @@ const credentials = z.object({
|
||||
});
|
||||
|
||||
const usageWindow = z
|
||||
.object({ utilization: z.number().finite(), resets_at: z.string().nullish() })
|
||||
.object({ utilization: z.number(), resets_at: z.string().nullish() })
|
||||
.nullish();
|
||||
const usagePayload = z.object({
|
||||
five_hour: usageWindow,
|
||||
@@ -38,7 +42,7 @@ const usagePayload = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.string(),
|
||||
percent: z.number().finite(),
|
||||
percent: z.number(),
|
||||
resets_at: z.string().nullish(),
|
||||
scope: z
|
||||
.object({
|
||||
@@ -63,7 +67,7 @@ function window(
|
||||
label,
|
||||
seconds,
|
||||
used: Math.max(0, Math.min(100, used)),
|
||||
resetsAt: resetsAt ? Date.parse(resetsAt) : Number.NaN,
|
||||
resetsAt: resetsAt === null || resetsAt === undefined ? Number.NaN : Date.parse(resetsAt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,31 +177,14 @@ export class ClaudeQuotaTracker {
|
||||
const credential = parsed.data.claudeAiOauth;
|
||||
if (this.disposed) return true;
|
||||
if (credential.expiresAt <= Date.now()) return false;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetcher(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${credential.accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (this.controller === controller) this.controller = undefined;
|
||||
}
|
||||
const response = await this.fetchUsage(credential.accessToken);
|
||||
if (!response.ok) return false;
|
||||
// SAFETY: Response.json yields JSON values; usagePayload validates fields used.
|
||||
const payload = (await response.json()) as Schema.Json;
|
||||
const payload = jsonValue.parse(await response.json());
|
||||
const snapshot = normalizeClaudeQuota(
|
||||
payload,
|
||||
credential.subscriptionType ?? "unknown",
|
||||
);
|
||||
if (this.disposed) return true;
|
||||
if (this.isDisposed()) return true;
|
||||
if (!snapshot) return false;
|
||||
this.latest = snapshot;
|
||||
} catch {
|
||||
@@ -207,6 +194,32 @@ export class ClaudeQuotaTracker {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async fetchUsage(accessToken: string) {
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, TIMEOUT_MS);
|
||||
try {
|
||||
return await this.fetcher(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (this.controller === controller) this.controller = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the flag through a call so checks after `await` are not narrowed away. */
|
||||
private isDisposed() {
|
||||
return this.disposed;
|
||||
}
|
||||
|
||||
status(): QuotaStatus | undefined {
|
||||
if (!this.latest) return;
|
||||
return {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default Plugin.define({
|
||||
await quota.authChanged();
|
||||
} else if (
|
||||
event.type === "credential.switched" &&
|
||||
String(event.data.integrationID) === "openai"
|
||||
event.data.integrationID === "openai"
|
||||
) {
|
||||
await quota.authChanged(true);
|
||||
}
|
||||
|
||||
+69
-45
@@ -8,24 +8,30 @@ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const POLL_MS = 60_000;
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const STORAGE_KEY = "chatgpt-quota";
|
||||
const jsonValue = z.json();
|
||||
|
||||
function compactLimitIdentity(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
return value.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "");
|
||||
}
|
||||
|
||||
function isSparkLimit(meteredFeature?: string, limitName?: string | null) {
|
||||
return (
|
||||
(meteredFeature !== undefined &&
|
||||
compactLimitIdentity(meteredFeature) === "codexbengalfox") ||
|
||||
(limitName != null && compactLimitIdentity(limitName) === "gpt53codexspark")
|
||||
(limitName !== null &&
|
||||
limitName !== undefined &&
|
||||
compactLimitIdentity(limitName) === "gpt53codexspark")
|
||||
);
|
||||
}
|
||||
|
||||
function hasReserveToken(value: string) {
|
||||
return /(^|[^a-z0-9])reserve($|[^a-z0-9])/iu.test(value);
|
||||
}
|
||||
|
||||
function isReserveLimit(meteredFeature?: string, limitName?: string | null) {
|
||||
const hasReserveToken = (value: string) => /(^|[^a-z0-9])reserve($|[^a-z0-9])/i.test(value);
|
||||
return (
|
||||
(meteredFeature !== undefined && hasReserveToken(meteredFeature)) ||
|
||||
(limitName != null && hasReserveToken(limitName))
|
||||
(limitName !== null && limitName !== undefined && hasReserveToken(limitName))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +87,10 @@ function withoutExcludedState(state: StoredState): StoredState {
|
||||
const windows = state.latest?.windows.filter((window) => !isExcludedWindow(window));
|
||||
return {
|
||||
...state,
|
||||
latest: state.latest && windows?.length ? { ...state.latest, windows } : undefined,
|
||||
latest:
|
||||
state.latest && windows !== undefined && windows.length > 0
|
||||
? { ...state.latest, windows }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,9 +98,9 @@ type QuotaContext = Pick<Plugin.Context, "integration" | "storage">;
|
||||
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const quotaWindow = z.object({
|
||||
limit_window_seconds: z.number().finite().positive(),
|
||||
used_percent: z.number().finite(),
|
||||
reset_at: z.number().finite(),
|
||||
limit_window_seconds: z.number().positive(),
|
||||
used_percent: z.number(),
|
||||
reset_at: z.number(),
|
||||
});
|
||||
const additionalRateLimitIdentity = z.object({
|
||||
limit_name: z.string().optional(),
|
||||
@@ -187,7 +196,7 @@ export function normalizeQuota(
|
||||
function accountID(credential: Credential.OAuth) {
|
||||
const metadataID = z
|
||||
.string()
|
||||
.safeParse(credential.metadata?.accountID ?? credential.metadata?.accountId);
|
||||
.safeParse(credential.metadata?.["accountID"] ?? credential.metadata?.["accountId"]);
|
||||
if (metadataID.success) return metadataID.data;
|
||||
const parts = credential.access.split(".");
|
||||
if (parts.length !== 3) return;
|
||||
@@ -200,7 +209,7 @@ function accountID(credential: Credential.OAuth) {
|
||||
.optional(),
|
||||
organizations: z.array(z.object({ id: z.string() })).optional(),
|
||||
})
|
||||
.safeParse(JSON.parse(Buffer.from(parts[1]!, "base64url").toString()));
|
||||
.safeParse(JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString()));
|
||||
if (!claims.success) return;
|
||||
return (
|
||||
claims.data.chatgpt_account_id ??
|
||||
@@ -250,23 +259,30 @@ export class QuotaTracker {
|
||||
return;
|
||||
}
|
||||
const id = accountID(credential);
|
||||
const account = id
|
||||
? await fingerprint(`account:${id}`)
|
||||
: (this.state?.account ?? (await fingerprint(`credential:${credential.refresh}`)));
|
||||
const account =
|
||||
id !== undefined && id !== ""
|
||||
? await fingerprint(`account:${id}`)
|
||||
: (this.state?.account ?? (await fingerprint(`credential:${credential.refresh}`)));
|
||||
if (generation !== this.generation) return;
|
||||
if (this.state?.account !== account) {
|
||||
const value = await this.context.storage.get(STORAGE_KEY);
|
||||
const parsed = storedState.safeParse(value);
|
||||
if (generation !== this.generation) return;
|
||||
const stored = parsed.success ? parsed.data : undefined;
|
||||
if (generation !== this.generation) return;
|
||||
this.state = stored?.account === account ? withoutExcludedState(stored) : { account };
|
||||
}
|
||||
if (this.state?.account !== account) await this.load(account, generation);
|
||||
if (generation !== this.generation) return;
|
||||
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
||||
this.timer ??= setInterval(() => void this.refresh(), this.pollMs);
|
||||
return credential;
|
||||
}
|
||||
|
||||
private async load(account: string, generation: number) {
|
||||
const value = await this.context.storage.get(STORAGE_KEY);
|
||||
const parsed = storedState.safeParse(value);
|
||||
if (generation !== this.generation) return;
|
||||
const stored = parsed.success ? parsed.data : undefined;
|
||||
this.state = stored?.account === account ? withoutExcludedState(stored) : { account };
|
||||
}
|
||||
|
||||
/** Reads state through a call so checks after `await` are not narrowed away. */
|
||||
private current(): StoredState | undefined {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private async clear() {
|
||||
this.state = undefined;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
@@ -297,32 +313,20 @@ export class QuotaTracker {
|
||||
try {
|
||||
const credential = await this.resolveCredential(generation);
|
||||
if (!credential || !this.state || generation !== this.generation) return;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${credential.access}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
const id = accountID(credential);
|
||||
if (id) headers.set("ChatGPT-Account-Id", id);
|
||||
response = await this.fetcher(USAGE_URL, { headers, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (this.controller === controller) this.controller = undefined;
|
||||
}
|
||||
const response = await this.fetchUsage(credential);
|
||||
if (!response.ok || generation !== this.generation) return;
|
||||
// SAFETY: JSON.parse semantics from Response.json produce only JSON values;
|
||||
// quotaPayload validates all fields used below.
|
||||
const payload = (await response.json()) as Schema.Json;
|
||||
if (generation !== this.generation || !this.state) return;
|
||||
const payload = jsonValue.parse(await response.json());
|
||||
if (generation !== this.generation || !this.current()) return;
|
||||
const parsed = quotaPayload.safeParse(payload);
|
||||
if (parsed.success && parsed.data.account_id) {
|
||||
if (
|
||||
parsed.success &&
|
||||
parsed.data.account_id !== undefined &&
|
||||
parsed.data.account_id !== ""
|
||||
) {
|
||||
const account = await fingerprint(`account:${parsed.data.account_id}`);
|
||||
if (generation !== this.generation || !this.state) return;
|
||||
if (account !== this.state.account) this.state = { account };
|
||||
const state = this.current();
|
||||
if (generation !== this.generation || !state) return;
|
||||
if (account !== state.account) this.state = { account };
|
||||
}
|
||||
const snapshot = normalizeQuota(payload);
|
||||
if (!snapshot) return;
|
||||
@@ -335,6 +339,26 @@ export class QuotaTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUsage(credential: Credential.OAuth) {
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, TIMEOUT_MS);
|
||||
try {
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${credential.access}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
const id = accountID(credential);
|
||||
if (id !== undefined && id !== "") headers.set("ChatGPT-Account-Id", id);
|
||||
return await this.fetcher(USAGE_URL, { headers, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (this.controller === controller) this.controller = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private apply(snapshot: QuotaSnapshot) {
|
||||
if (!this.state) return;
|
||||
this.state.latest = snapshot;
|
||||
|
||||
+26
-25
@@ -34,9 +34,10 @@ function resetTiming(resetsAt: number, now: number) {
|
||||
}
|
||||
|
||||
export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) {
|
||||
const label = window.label
|
||||
? `${window.label} ${duration(window.seconds)}`
|
||||
: duration(window.seconds);
|
||||
const label =
|
||||
window.label !== null && window.label !== ""
|
||||
? `${window.label} ${duration(window.seconds)}`
|
||||
: duration(window.seconds);
|
||||
return `${label}: ${percent(window.remaining)}% · ${resetTiming(window.resetsAt, now)}`;
|
||||
}
|
||||
|
||||
@@ -60,6 +61,26 @@ export default Plugin.define({
|
||||
setRevision((value) => value + 1);
|
||||
});
|
||||
|
||||
const Section = (section: { title: string; status: QuotaStatus | null }) => (
|
||||
<Show when={section.status}>
|
||||
{(status: () => QuotaStatus) => (
|
||||
<>
|
||||
<text fg={context.theme.text.default}>
|
||||
<b>
|
||||
{section.title} quota ({status().plan})
|
||||
</b>
|
||||
</text>
|
||||
<For each={status().windows}>
|
||||
{(window) => (
|
||||
<text fg={context.theme.text.muted}>
|
||||
{formatQuotaWindow(window)}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => {
|
||||
const [statuses] = createResource(
|
||||
() => (props.visible?.() === false ? false : `${props.instance}:${revision()}`),
|
||||
@@ -71,30 +92,10 @@ export default Plugin.define({
|
||||
return chatgpt || claude ? { chatgpt, claude } : null;
|
||||
},
|
||||
);
|
||||
const Section = (section: { title: string; status: QuotaStatus | null }) => (
|
||||
<Show when={section.status}>
|
||||
{(status: () => QuotaStatus) => (
|
||||
<>
|
||||
<text fg={context.theme.text.default}>
|
||||
<b>
|
||||
{section.title} quota ({status().plan})
|
||||
</b>
|
||||
</text>
|
||||
<For each={status().windows}>
|
||||
{(window) => (
|
||||
<text fg={context.theme.text.muted}>
|
||||
{formatQuotaWindow(window)}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
const Content = () => (
|
||||
<box flexDirection="column">
|
||||
<Section title="ChatGPT" status={statuses()!.chatgpt} />
|
||||
<Section title="Claude" status={statuses()!.claude} />
|
||||
<Section title="ChatGPT" status={statuses()?.chatgpt ?? null} />
|
||||
<Section title="Claude" status={statuses()?.claude ?? null} />
|
||||
</box>
|
||||
);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user