@@ -12,7 +12,7 @@ Choose any plugin subset; Multi-work requires Agents first.
|
||||
|
||||
Quota shows ChatGPT limits for the connected OpenAI account and, when Claude Code is logged in, Claude subscription limits read from `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (read-only; Claude Code refreshes the token).
|
||||
|
||||
Optchat implements [VictorTaelin's optchat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449). It is off by default; run `/optchat on`, `/optchat off`, or `/optchat status` in a root session. In an enabled session, each turn sees a constant-size view of the whole chat, made of one-line summaries, plus the new message. The model can open any line with the `zoom` tool and read message times with `date`. Optchat does not apply to subagent sessions. Memory is stored in `~/.local/share/opencode/optchat/<sessionID>/`. Compactions run in a child session named "optchat compactions". The plugin accepts two options: `compactionModel` (default `opencode/claude-haiku-5-5#xhigh`) and `dataDir`.
|
||||
Optchat implements [VictorTaelin's optchat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449). It is off by default; run `/optchat on`, `/optchat off`, or `/optchat status` in a root session. In an enabled session, each turn sees a constant-size view of the whole chat, made of one-line summaries, plus the new message. The model can open any line with the `zoom` tool and read message times with `date`. Optchat does not apply to subagent sessions. Memory is stored in `~/.local/share/opencode/optchat/<sessionID>/`. Compactions run in a child session named "optchat compactions". Optchat activates only when the `compactionModel` option is set, in the format `provider/model[#variant]` (for example `opencode/claude-haiku-5-5#xhigh`); without it (or when empty) the plugin registers nothing. A non-empty `dataDir` overrides the memory location. The installer writes both options as empty strings for you to fill in.
|
||||
|
||||
## Local installation
|
||||
|
||||
@@ -21,12 +21,11 @@ From a local checkout, run:
|
||||
```sh
|
||||
bash /path/to/stanp-opencode/install-local.sh --plugins agents,multi-work,quota
|
||||
opencode service restart
|
||||
# Connect OpenAI and select coordinate in OpenCode.
|
||||
```
|
||||
|
||||
## GPT configuration
|
||||
## Configuration
|
||||
|
||||
Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates.
|
||||
Agents registers nothing until its `agents` option is set. The installer writes every missing option with an empty value (`""`) for you to fill in; an empty value means unset, so a fresh install stays inert. Each assignment is `provider/model[#variant]`; the variant selects the model's reasoning variant, falling back to a `reasoningEffort` request setting when the model does not advertise it. Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates. The `deep-lo` and `deep-hi` assignments are optional as a pair; if either is omitted, both deep workers are disabled and orchestration uses `good-lo`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -37,39 +36,13 @@ Merge this into `~/.config/opencode/opencode.json`, preserving unrelated setting
|
||||
{
|
||||
"package": "./plugins/stanp-opencode/agents",
|
||||
"options": {
|
||||
"mode": "gpt",
|
||||
"provider": "openai",
|
||||
// Priority may cost more; set false to opt out.
|
||||
"lunaPriority": true,
|
||||
},
|
||||
},
|
||||
{ "package": "./plugins/stanp-opencode/multi-work" },
|
||||
{ "package": "./plugins/stanp-opencode/quota" },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Custom model configuration
|
||||
|
||||
Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates. The `deep-lo` and `deep-hi` assignments are optional as a pair; if either is omitted, both deep workers are disabled and orchestration uses `good-lo`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// Required at the root and in worktrees.
|
||||
"experimental": { "subagent_depth": 2 },
|
||||
"plugins": [
|
||||
{
|
||||
"package": "./plugins/stanp-opencode/agents",
|
||||
"options": {
|
||||
"mode": "custom",
|
||||
"agents": {
|
||||
"fast-lo": { "model": "openai/gpt-6-luna", "effort": "low" },
|
||||
"fast-hi": { "model": "openai/gpt-6-luna", "effort": "high" },
|
||||
"good-lo": { "model": "openai/gpt-6.1-sol", "effort": "low" },
|
||||
"good-hi": { "model": "openai/gpt-6.1-sol", "effort": "high" },
|
||||
"deep-lo": { "model": "openai/gpt-6-astra", "effort": "low" },
|
||||
"deep-hi": { "model": "openai/gpt-6-astra", "effort": "high" },
|
||||
"fast-lo": "openai/gpt-6-luna#low",
|
||||
"fast-hi": "openai/gpt-6-luna#high",
|
||||
"good-lo": "openai/gpt-6.1-sol#low",
|
||||
"good-hi": "openai/gpt-6.1-sol#high",
|
||||
"deep-lo": "openai/gpt-6-astra#low",
|
||||
"deep-hi": "openai/gpt-6-astra#high",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+30
-2
@@ -9,6 +9,35 @@ const entrySchema = z
|
||||
.object({ package: z.string(), options: z.record(z.string(), z.json()).optional() })
|
||||
.catchall(z.json());
|
||||
type Entry = z.infer<typeof entrySchema>;
|
||||
type Options = NonNullable<Entry["options"]>;
|
||||
|
||||
// Every option the plugins read, empty so users can fill them in; plugins treat "" as unset.
|
||||
function skeleton(name: (typeof names)[number]): Options {
|
||||
if (name === "agents")
|
||||
return {
|
||||
agents: Object.fromEntries(
|
||||
["fast-lo", "fast-hi", "good-lo", "good-hi", "deep-lo", "deep-hi"].map((worker) => [
|
||||
worker,
|
||||
"",
|
||||
]),
|
||||
),
|
||||
};
|
||||
if (name === "optchat") return { compactionModel: "", dataDir: "" };
|
||||
return {};
|
||||
}
|
||||
const nested = z.record(z.string(), z.json());
|
||||
/** Adds missing keys from `defaults` without overwriting existing values. */
|
||||
function fill(target: Options, defaults: Options) {
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
const existing = nested.safeParse(target[key]);
|
||||
const missing = nested.safeParse(value);
|
||||
if (target[key] === undefined) target[key] = structuredClone(value);
|
||||
else if (existing.success && missing.success) {
|
||||
fill(existing.data, missing.data);
|
||||
target[key] = existing.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pluginSchema = z.union([z.string(), entrySchema]);
|
||||
const registrationSchema = z
|
||||
.union([z.string().transform((packageName) => ({ package: packageName })), entrySchema])
|
||||
@@ -105,10 +134,9 @@ export async function install(source: string, config: string, selection?: string
|
||||
(name === "agents" && monolith(entry.package)),
|
||||
);
|
||||
const options: NonNullable<Entry["options"]> = {};
|
||||
if (name === "agents" && !prior?.options)
|
||||
Object.assign(options, { mode: "gpt", provider: "openai", lunaPriority: true });
|
||||
Object.assign(options, prior?.options);
|
||||
delete options.playwright;
|
||||
fill(options, skeleton(name));
|
||||
const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` });
|
||||
delete entry.options;
|
||||
if (Object.keys(options).length) entry.options = options;
|
||||
|
||||
+37
-53
@@ -1,7 +1,8 @@
|
||||
import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
|
||||
import type { ModelInfo } from "@opencode/client";
|
||||
import { z } from "zod";
|
||||
import { gptAgents, settings, workerNames } from "./config.js";
|
||||
import { settings, workerNames } from "./config.js";
|
||||
import { model } from "./model.js";
|
||||
import { discover, modelKey } from "./provider.js";
|
||||
import { descriptions, routingGuide } from "./routing.js";
|
||||
import { Skill } from "@opencode/schema/skill";
|
||||
@@ -37,12 +38,16 @@ const delegation = z.object({ agent: z.string() });
|
||||
export default Plugin.define({
|
||||
id: "stanp-opencode.agents",
|
||||
async setup(context) {
|
||||
const selected = settings.parse({ mode: "gpt", ...context.options });
|
||||
const lunaPriority =
|
||||
selected.mode === "gpt" &&
|
||||
selected.provider === "openai" &&
|
||||
selected.lunaPriority === true;
|
||||
let models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
|
||||
const { agents } = settings.parse(context.options);
|
||||
if (!workerNames.some((name) => 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}`);
|
||||
else if (!name.startsWith("deep-")) throw new Error(`agents ${name} is required`);
|
||||
}
|
||||
let models = new Map<string, ModelInfo>();
|
||||
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
||||
Agent.ID.make("build"),
|
||||
).permissions;
|
||||
@@ -64,7 +69,7 @@ export default Plugin.define({
|
||||
continue;
|
||||
}
|
||||
if (event.type === "model.updated") {
|
||||
models = await discover(context, lunaPriority);
|
||||
models = await discover(context);
|
||||
await context.agent.reload();
|
||||
} else if (event.type === "agent.updated") {
|
||||
// The configured build transform may run after ours. Read the final
|
||||
@@ -84,25 +89,13 @@ export default Plugin.define({
|
||||
if (!controller.signal.aborted)
|
||||
console.error("stanp-opencode model update failed", error);
|
||||
});
|
||||
models = await discover(context, lunaPriority);
|
||||
models = await discover(context);
|
||||
const { data: initialAgents } = await context.agent.list();
|
||||
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||
permissionKey = JSON.stringify(inherited);
|
||||
const registration = await context.rpc.register(AgentsRpc, {
|
||||
permissions: async () => inherited.map((rule) => ({ ...rule })),
|
||||
});
|
||||
const assignments =
|
||||
selected.mode === "gpt"
|
||||
? Object.fromEntries(
|
||||
workerNames.map((name) => [
|
||||
name,
|
||||
{
|
||||
...gptAgents[name],
|
||||
provider: selected.provider,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: selected.agents;
|
||||
let workers: (typeof workerNames)[number][] = [];
|
||||
const active = new Set<string>();
|
||||
|
||||
@@ -136,19 +129,8 @@ export default Plugin.define({
|
||||
});
|
||||
|
||||
await context.agent.transform((editor) => {
|
||||
const coordinates = (assignment: { model: string }) => {
|
||||
if (selected.mode === "gpt")
|
||||
return { provider: selected.provider, model: assignment.model };
|
||||
const slash = assignment.model.indexOf("/");
|
||||
return {
|
||||
provider: assignment.model.slice(0, slash),
|
||||
model: assignment.model.slice(slash + 1),
|
||||
};
|
||||
};
|
||||
const availableFor = (assignment: { model: string }) => {
|
||||
const { provider, model } = coordinates(assignment);
|
||||
return models.get(modelKey(provider, model));
|
||||
};
|
||||
const availableFor = (assignment: { providerID: string; id: string }) =>
|
||||
models.get(modelKey(assignment.providerID, assignment.id));
|
||||
const deepLo = assignments["deep-lo"];
|
||||
const deep =
|
||||
deepLo !== undefined &&
|
||||
@@ -163,30 +145,31 @@ export default Plugin.define({
|
||||
: [];
|
||||
active.clear();
|
||||
for (const name of workers) active.add(name);
|
||||
if (workers.length === 0) return;
|
||||
if (workers.length === 0 || coordinate === undefined) return;
|
||||
for (const name of workers) {
|
||||
const assignment = assignments[name]!;
|
||||
const available = availableFor(assignment)!;
|
||||
editor.update(name, (agent) => {
|
||||
const hasVariant = available.model.variants.some(
|
||||
({ id }) => id === assignment.effort,
|
||||
);
|
||||
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.model.providerID),
|
||||
id: Model.ID.make(available.model.id),
|
||||
providerID: Provider.ID.make(available.providerID),
|
||||
id: Model.ID.make(available.id),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.effort);
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.variant!);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: assignment.effort },
|
||||
settings:
|
||||
hasVariant || assignment.variant === undefined
|
||||
? {}
|
||||
: { reasoningEffort: assignment.variant },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
if (lunaPriority && available.serviceTier)
|
||||
agent.request.settings.serviceTier = available.serviceTier;
|
||||
agent.permissions = [
|
||||
...structuredClone(inherited),
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
@@ -195,26 +178,27 @@ export default Plugin.define({
|
||||
}
|
||||
editor.update("coordinate", (agent) => {
|
||||
const available = availableFor(coordinate)!;
|
||||
const hasVariant = available.model.variants.some(
|
||||
({ id }) => id === coordinate.effort,
|
||||
);
|
||||
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.model.providerID),
|
||||
id: Model.ID.make(available.model.id),
|
||||
providerID: Provider.ID.make(available.providerID),
|
||||
id: Model.ID.make(available.id),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort);
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.variant!);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: coordinate.effort },
|
||||
settings:
|
||||
hasVariant || coordinate.variant === undefined
|
||||
? {}
|
||||
: { reasoningEffort: coordinate.variant },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
if (lunaPriority && available.serviceTier)
|
||||
agent.request.settings.serviceTier = available.serviceTier;
|
||||
agent.system = routingGuide(deep);
|
||||
agent.permissions = [
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
|
||||
+6
-57
@@ -9,61 +9,10 @@ export const workerNames = [
|
||||
"deep-hi",
|
||||
] as const;
|
||||
|
||||
const gptAssignment = z.strictObject({
|
||||
model: z.string().trim().min(1),
|
||||
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]),
|
||||
// Each assignment is provider/model[#variant]; an empty string means unset. Without any
|
||||
// assignment the plugin registers nothing.
|
||||
export const settings = z.object({
|
||||
agents: z
|
||||
.strictObject(Object.fromEntries(workerNames.map((name) => [name, z.string().optional()])))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const customAssignment = gptAssignment.extend({
|
||||
model: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((model) => {
|
||||
const slash = model.indexOf("/");
|
||||
return slash > 0 && slash < model.length - 1;
|
||||
}, "model must use the provider/model convention"),
|
||||
});
|
||||
|
||||
export const settings = z.discriminatedUnion("mode", [
|
||||
z
|
||||
.strictObject({
|
||||
mode: z.literal("gpt"),
|
||||
provider: z.string().trim().min(1).default("openai"),
|
||||
lunaPriority: z.boolean().optional(),
|
||||
})
|
||||
.transform((value, context) => {
|
||||
if (value.provider !== "openai" && value.lunaPriority !== undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["lunaPriority"],
|
||||
message: "lunaPriority is only supported by the openai provider",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return value.provider === "openai"
|
||||
? { ...value, lunaPriority: value.lunaPriority ?? true }
|
||||
: value;
|
||||
}),
|
||||
z.strictObject({
|
||||
mode: z.literal("custom"),
|
||||
agents: z.strictObject({
|
||||
"fast-lo": customAssignment,
|
||||
"fast-hi": customAssignment,
|
||||
"good-lo": customAssignment,
|
||||
"good-hi": customAssignment,
|
||||
"deep-lo": customAssignment.optional(),
|
||||
"deep-hi": customAssignment.optional(),
|
||||
// Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo.
|
||||
coordinate: customAssignment.optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const gptAgents = {
|
||||
"fast-lo": { model: "gpt-6-luna", effort: "low" },
|
||||
"fast-hi": { model: "gpt-6-luna", effort: "high" },
|
||||
"good-lo": { model: "gpt-6.1-sol", effort: "low" },
|
||||
"good-hi": { model: "gpt-6.1-sol", effort: "high" },
|
||||
"deep-lo": { model: "gpt-6-astra", effort: "low" },
|
||||
"deep-hi": { model: "gpt-6-astra", effort: "high" },
|
||||
} satisfies Record<(typeof workerNames)[number], z.infer<typeof gptAssignment>>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Parses `provider/model[#variant]`; `name` prefixes the error for the offending option. */
|
||||
export function model(reference: string, name: string) {
|
||||
const [path = "", variant] = reference.split("#");
|
||||
const slash = path.indexOf("/");
|
||||
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 };
|
||||
}
|
||||
+10
-14
@@ -34,13 +34,18 @@ import {
|
||||
type Line,
|
||||
} from "./optchat-core.js";
|
||||
import { Memory } from "./optchat-memory.js";
|
||||
import { model } from "./model.js";
|
||||
|
||||
const settings = z.object({
|
||||
/** provider/model#variant used for compactions. */
|
||||
compactionModel: z.string().default("opencode/claude-haiku-5-5#xhigh"),
|
||||
/** provider/model[#variant] used for compactions; without it optchat does not apply. */
|
||||
compactionModel: z.string().optional(),
|
||||
/** Empty means the default directory. */
|
||||
dataDir: z
|
||||
.string()
|
||||
.default(
|
||||
.optional()
|
||||
.transform(
|
||||
(value) =>
|
||||
value ||
|
||||
join(
|
||||
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||
"opencode",
|
||||
@@ -199,21 +204,12 @@ function turnStart(history: readonly Entry[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function model(reference: string) {
|
||||
const [path = "", variant] = reference.split("#");
|
||||
const slash = path.indexOf("/");
|
||||
if (slash <= 0)
|
||||
throw new Error(`optchat compactionModel 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 };
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "stanp-opencode.optchat",
|
||||
setup: async (context) => {
|
||||
const options = settings.parse(context.options);
|
||||
const compactionModel = model(options.compactionModel);
|
||||
if (!options.compactionModel) return;
|
||||
const compactionModel = model(options.compactionModel, "optchat compactionModel");
|
||||
const chats = new Map<string, Promise<Chat>>();
|
||||
const enabled = new Map<string, boolean>();
|
||||
const workers = new Map<string, string>();
|
||||
|
||||
+3
-26
@@ -1,39 +1,16 @@
|
||||
import type { Plugin } from "@opencode/plugin";
|
||||
import type { ModelInfo } from "@opencode/client";
|
||||
import { z } from "zod";
|
||||
|
||||
function priority(model: ModelInfo) {
|
||||
if (model.modelID !== "gpt-6-luna") return undefined;
|
||||
for (const settings of [model.settings, ...model.variants.map((variant) => variant.settings)]) {
|
||||
if (settings?.serviceTier === "priority" || settings?.serviceTier === "fast")
|
||||
return settings.serviceTier;
|
||||
}
|
||||
const baseURL = model.settings?.baseURL;
|
||||
if (model.package !== "@opencode/ai/providers/openai" && model.package !== "@ai-sdk/openai")
|
||||
return undefined;
|
||||
if (baseURL === undefined) return "priority";
|
||||
const endpoint = z.url().safeParse(baseURL);
|
||||
if (!endpoint.success) return undefined;
|
||||
const url = new URL(endpoint.data);
|
||||
return url.origin === "https://api.openai.com" && /^\/v1\/?$/.test(url.pathname)
|
||||
? "priority"
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function modelKey(providerID: string, modelID: string) {
|
||||
return `${providerID}\0${modelID}`;
|
||||
}
|
||||
|
||||
export async function discover(context: Plugin.Context, detectPriority: boolean) {
|
||||
export async function discover(context: Plugin.Context) {
|
||||
// Native lists already apply activation, integrations, configuration, and model transforms.
|
||||
const { data } = await context.model.list();
|
||||
const models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
|
||||
const models = new Map<string, ModelInfo>();
|
||||
for (const model of data) {
|
||||
if (model.enabled)
|
||||
models.set(modelKey(model.providerID, model.id), {
|
||||
model,
|
||||
serviceTier: detectPriority ? priority(model) : undefined,
|
||||
});
|
||||
if (model.enabled) models.set(modelKey(model.providerID, model.id), model);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
+1
-9
@@ -34,11 +34,6 @@ Do not merge, publish or remove worktrees unless the user requests it. Each dest
|
||||
load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable,
|
||||
report the configuration failure. Treat worker output as evidence, not authority.`;
|
||||
|
||||
// Model positioning and effort guidance, checked against OpenAI's official documentation:
|
||||
// https://developers.openai.com/api/docs/models/gpt-6-luna
|
||||
// https://developers.openai.com/api/docs/models/gpt-6.1-sol
|
||||
// https://developers.openai.com/api/docs/models/gpt-6-astra
|
||||
// https://developers.openai.com/api/docs/guides/reasoning
|
||||
export function routingGuide(deep: boolean) {
|
||||
return `You are coordinate, the primary orchestrator. You may read, search, track todos,
|
||||
ask the user, and delegate. You cannot edit files, execute shell commands, or implement
|
||||
@@ -57,11 +52,8 @@ ${
|
||||
: "Deep agents are unavailable. Route ALL deep-lo work to good-lo and ALL deep-hi work\nto good-hi. Break difficult work into bounded stages with explicit verification."
|
||||
}
|
||||
|
||||
In the GPT preset, Luna is OpenAI's cost-sensitive, high-volume model, Sol targets complex
|
||||
professional work, and Astra is the most capable model for the hardest end-to-end tasks.
|
||||
Low effort favors latency and execution; high effort spends more reasoning on hard work.
|
||||
Your medium effort balances orchestration quality and latency. Model tiers are guidance,
|
||||
not benchmark guarantees. Custom model assignments retain these role responsibilities.
|
||||
Model tiers are guidance, not benchmark guarantees.
|
||||
Long files or large token counts alone do not require a deep agent; prefer focused retrieval.
|
||||
|
||||
Delegate self-contained assignments: goal, relevant context/files, constraints, ownership,
|
||||
|
||||
+10
-1
@@ -238,7 +238,16 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
|
||||
// The adapter reads these domain objects at construction; unimplemented operations
|
||||
// stay undefined and fail loudly if the plugin starts using them.
|
||||
const context = {
|
||||
options: { provider: "fixture" },
|
||||
options: {
|
||||
agents: {
|
||||
"fast-lo": "fixture/gpt-6-luna#low",
|
||||
"fast-hi": "fixture/gpt-6-luna#high",
|
||||
"good-lo": "fixture/gpt-6.1-sol#low",
|
||||
"good-hi": "fixture/gpt-6.1-sol#high",
|
||||
"deep-lo": "fixture/gpt-6-astra#low",
|
||||
"deep-hi": "fixture/gpt-6-astra#high",
|
||||
},
|
||||
},
|
||||
location,
|
||||
app: { version: "2.0.22-fixture" },
|
||||
command: {},
|
||||
|
||||
+46
-6
@@ -30,6 +30,16 @@ const unrelated = {
|
||||
},
|
||||
};
|
||||
const names = ["agents", "multi-work", "quota", "optchat"];
|
||||
const empty = {
|
||||
agents: {
|
||||
"fast-lo": "",
|
||||
"fast-hi": "",
|
||||
"good-lo": "",
|
||||
"good-hi": "",
|
||||
"deep-lo": "",
|
||||
"deep-hi": "",
|
||||
},
|
||||
};
|
||||
const helper = new URL("../scripts/install.ts", import.meta.url);
|
||||
async function run(
|
||||
command: string[],
|
||||
@@ -123,7 +133,9 @@ test("upgrades retain selection and options; explicit selection replaces only ow
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "agents,quota");
|
||||
const current = await contents(f.config);
|
||||
current.plugins[1].options = { mode: "gpt", provider: "openai", lunaPriority: false };
|
||||
current.plugins[1].options = {
|
||||
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||
};
|
||||
current.plugins.push({ package: "after", options: { untouched: 1 } });
|
||||
await writeFile(join(f.config, "opencode.json"), JSON.stringify(current));
|
||||
await install(f.source, f.config);
|
||||
@@ -159,7 +171,7 @@ test("mixed string/object registrations upgrade without flags and preserve unrel
|
||||
before,
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
after,
|
||||
@@ -188,7 +200,7 @@ test("owned string registrations retain their selection and explicit selection r
|
||||
"before",
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
@@ -216,7 +228,7 @@ for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absol
|
||||
"before",
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
@@ -225,6 +237,34 @@ for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absol
|
||||
});
|
||||
}
|
||||
|
||||
test("installer writes empty option skeletons, fills only missing keys, and is idempotent", async () => {
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "agents,multi-work,quota,optchat");
|
||||
const fresh = await contents(f.config);
|
||||
expect(fresh.plugins.slice(1)).toEqual([
|
||||
{ package: "./plugins/stanp-opencode/agents", options: empty },
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode/optchat",
|
||||
options: { compactionModel: "", dataDir: "" },
|
||||
},
|
||||
]);
|
||||
fresh.plugins[1].options = { agents: { "fast-lo": "openai/gpt-6-luna#low" }, extra: 1 };
|
||||
fresh.plugins[4].options = { compactionModel: "acme/cheap" };
|
||||
await writeFile(join(f.config, "opencode.json"), JSON.stringify(fresh));
|
||||
await install(f.source, f.config);
|
||||
const filled = await contents(f.config);
|
||||
expect(filled.plugins[1].options).toEqual({
|
||||
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||
extra: 1,
|
||||
});
|
||||
expect(filled.plugins[4].options).toEqual({ compactionModel: "acme/cheap", dataDir: "" });
|
||||
expect(filled).toMatchObject(unrelated);
|
||||
await install(f.source, f.config);
|
||||
expect(await contents(f.config)).toEqual(filled);
|
||||
});
|
||||
|
||||
test("local installer retains executable permissions and supports direct invocation", async () => {
|
||||
const f = await fixture();
|
||||
const path = new URL("../install-local.sh", import.meta.url).pathname;
|
||||
@@ -244,7 +284,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
||||
{ package: "before" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: false },
|
||||
options: { agents: { "fast-lo": "openai/gpt-6-luna#low" } },
|
||||
},
|
||||
{ package: "stanp-opencode" },
|
||||
{ package: "after" },
|
||||
@@ -263,7 +303,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
||||
.map((name) => `./plugins/stanp-opencode/${name}`),
|
||||
"after",
|
||||
]);
|
||||
expect(result.plugins[1].options.lunaPriority).toBe(false);
|
||||
expect(result.plugins[1].options.agents["fast-lo"]).toBe("openai/gpt-6-luna#low");
|
||||
expect(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
|
||||
expect(result).toMatchObject(unrelated);
|
||||
});
|
||||
|
||||
@@ -437,3 +437,26 @@ test("checkpoints are not logged, provider-executed calls end a turn, and empty
|
||||
"user: next",
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([{}, { compactionModel: "", dataDir: "" }])(
|
||||
"without compactionModel (%j), optchat registers nothing",
|
||||
async (options) => {
|
||||
const registered: string[] = [];
|
||||
const record = (name: string) => async () => {
|
||||
registered.push(name);
|
||||
return { dispose: async () => {} };
|
||||
};
|
||||
const context = {
|
||||
options,
|
||||
tool: { transform: record("tool") },
|
||||
command: { transform: record("command") },
|
||||
session: { hook: record("hook") },
|
||||
};
|
||||
// SAFETY: setup must return before touching any host service; a call is recorded and fails the test.
|
||||
const setup = optchat.setup as (
|
||||
context: Pick<Plugin.Context, "options">,
|
||||
) => ReturnType<typeof optchat.setup>;
|
||||
expect(await setup(context)).toBeUndefined();
|
||||
expect(registered).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
+43
-41
@@ -8,7 +8,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types";
|
||||
import type { Skill } from "@opencode/schema/skill";
|
||||
import type { ModelInfo } from "@opencode/client";
|
||||
import plugin from "../src/agents.js";
|
||||
import { settings, gptAgents, workerNames } from "../src/config.js";
|
||||
import { model } from "../src/model.js";
|
||||
import { z } from "zod";
|
||||
import { AgentsRpc } from "../src/agents-rpc.js";
|
||||
|
||||
@@ -23,9 +23,17 @@ const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({
|
||||
settings: { reasoningEffort: effort },
|
||||
})),
|
||||
}));
|
||||
const agents = {
|
||||
"fast-lo": "test/gpt-6-luna#low",
|
||||
"fast-hi": "test/gpt-6-luna#high",
|
||||
"good-lo": "test/gpt-6.1-sol#low",
|
||||
"good-hi": "test/gpt-6.1-sol#high",
|
||||
"deep-lo": "test/gpt-6-astra#low",
|
||||
"deep-hi": "test/gpt-6-astra#high",
|
||||
};
|
||||
async function host(
|
||||
catalog: ModelInfo[] = models,
|
||||
options: Plugin.Context["options"] = { provider: "test" },
|
||||
options: Plugin.Context["options"] = { agents },
|
||||
existingSkill?: Skill.Info,
|
||||
) {
|
||||
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
||||
@@ -233,7 +241,7 @@ test("Agents is independently loadable with no quota, storage, MCP, or custom to
|
||||
test("preserves an existing user anti-overengineering skill", async () => {
|
||||
const first = await host();
|
||||
const original = { ...first.skills.get("anti-overengineering")!, content: "user definition" };
|
||||
const h = await host(models, { provider: "test" }, original);
|
||||
const h = await host(models, { agents }, original);
|
||||
expect(h.skills.get("anti-overengineering")).toEqual(original);
|
||||
});
|
||||
|
||||
@@ -250,26 +258,44 @@ test("worker availability, deep fallback, and model refresh remain provider awar
|
||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
||||
});
|
||||
|
||||
test("custom models use provider-qualified names and optional deep assignments", async () => {
|
||||
const assignments = Object.fromEntries(
|
||||
workerNames
|
||||
.filter((name) => !name.startsWith("deep-"))
|
||||
.map((name) => [name, { ...gptAgents[name], model: `test/${gptAgents[name].model}` }]),
|
||||
test("without agents options or with the empty skeleton nothing is registered", async () => {
|
||||
const skeleton = Object.fromEntries(Object.keys(agents).map((name) => [name, ""]));
|
||||
for (const options of [{}, { agents: skeleton }]) {
|
||||
const h = await host(models, options);
|
||||
expect([...h.agents.keys()]).toEqual(["build"]);
|
||||
expect(h.skills.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("empty deep pair disables deep; partially filled required assignments fail", async () => {
|
||||
const h = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } });
|
||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
|
||||
expect(h.agents.has("deep-lo")).toBe(false);
|
||||
await expect(host(models, { agents: { ...agents, "good-hi": "" } })).rejects.toThrow(
|
||||
"agents good-hi is required",
|
||||
);
|
||||
await expect(host(models, { agents: { "deep-lo": agents["deep-lo"] } })).rejects.toThrow(
|
||||
"agents fast-lo is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("assignments are provider-qualified with optional deep pair and variant", async () => {
|
||||
const shallow = Object.fromEntries(
|
||||
Object.entries(agents).filter(([name]) => !name.startsWith("deep-")),
|
||||
);
|
||||
const duplicate = models.map((model) => ({ ...model, providerID: Provider.ID.make("other") }));
|
||||
const h = await host([...duplicate, ...models], { mode: "custom", agents: assignments });
|
||||
const h = await host([...duplicate, ...models], { agents: shallow });
|
||||
expect(String(h.agents.get("coordinate")?.model?.providerID)).toBe("test");
|
||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
|
||||
expect(h.agents.has("deep-hi")).toBe(false);
|
||||
expect(settings.safeParse({ mode: "gpt", provider: "test", lunaPriority: true }).success).toBe(
|
||||
false,
|
||||
expect(model("a/b/c#high", "n")).toEqual({ providerID: "a", id: "b/c", variant: "high" });
|
||||
expect(model("a/b", "n")).toEqual({ providerID: "a", id: "b" });
|
||||
expect(() => model("unqualified#low", "agents fast-lo")).toThrow(
|
||||
"agents fast-lo must be provider/model[#variant]: unqualified#low",
|
||||
);
|
||||
expect(
|
||||
settings.safeParse({
|
||||
mode: "custom",
|
||||
agents: { ...assignments, "fast-lo": { model: "unqualified", effort: "low" } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
await expect(
|
||||
host(models, { agents: { ...agents, "fast-lo": "unqualified#low" } }),
|
||||
).rejects.toThrow("agents fast-lo must be provider/model[#variant]");
|
||||
});
|
||||
|
||||
test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => {
|
||||
@@ -351,30 +377,6 @@ test("reasoning uses advertised variants and falls back to request settings", as
|
||||
});
|
||||
});
|
||||
|
||||
test("Luna priority requires official or advertised support and can be disabled", async () => {
|
||||
const openai = models.map((model) => ({
|
||||
...model,
|
||||
providerID: Provider.ID.make("openai"),
|
||||
package: "@opencode/ai/providers/openai",
|
||||
}));
|
||||
const official = await host(openai, { provider: "openai" });
|
||||
expect(official.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("priority");
|
||||
expect(official.agents.get("good-lo")?.request?.settings.serviceTier).toBeUndefined();
|
||||
const disabled = await host(openai, { provider: "openai", lunaPriority: false });
|
||||
expect(disabled.agents.get("fast-hi")?.request?.settings.serviceTier).toBeUndefined();
|
||||
const proxy = openai.map((model) => ({
|
||||
...model,
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}));
|
||||
const unadvertised = await host(proxy, { provider: "openai" });
|
||||
expect(unadvertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBeUndefined();
|
||||
const advertised = await host(
|
||||
proxy.map((model) => ({ ...model, settings: { ...model.settings, serviceTier: "fast" } })),
|
||||
{ provider: "openai" },
|
||||
);
|
||||
expect(advertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("fast");
|
||||
});
|
||||
|
||||
test("disabled catalog entries do not authorize worker availability", async () => {
|
||||
const h = await host(
|
||||
models.map((model) => ({ ...model, enabled: model.id !== "gpt-6-astra" })),
|
||||
|
||||
Reference in New Issue
Block a user