Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)

Branch-length batch covering tooling, the model layer, the Web App and the public
surfaces. Highlights:

- Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each
  change touches, with a root CHANGELOG.md holding one line per release.
- Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a
  lock and keeps `pnpm install` current; `pnpm dev` runs server+web together.
- AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity`
  object in place of item-level `signature`/`phase`, threaded verbatim through Trace,
  replay and resume; malformed classification adapted to the new error types.
- Model layer: a model is always referenced by an explicit `(provider, model_id)` pair.
  The provider is never inferred, guessed or defaulted -- both the catalog inference and
  the unique-match config resolution are gone, and CLI, SDK, server routes and
  run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen
  Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group.
- Web App: catalog preset sync and per-group speed test on the Models page, positional
  slash commands, a markdown renderer, skill-library update reminders, and a vertically
  centred draft page whose upward menus size themselves to the room available.
- Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed
  benchmark results for both suites, and the demo videos playing on the landing page.

Includes the fixes from a full review of the branch: 23 confirmed findings, among them a
provider-inference bug that could send one vendor's API key to another vendor's endpoint,
and an Escape handler that destroyed the composer's contents unrecoverably.

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
@@ -53,6 +53,9 @@ const CARD_ICONS = {
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
/** Installed skill count (open book, same family as the skill library) */
skills:
"M12 6.5C10.5 5 8 4.5 4 5v12c4-.5 6.5 0 8 1.5 1.5-1.5 4-2 8-1.5V5c-4-.5-6.5 0-8 1.5zm0 0V18",
/** Usage (bar chart, same as sidebar "Usage Center") */
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
/** Traces (eye line icon: observability; follows text color, no fill) */
@@ -245,6 +248,13 @@ export function AgentsPage() {
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
{a.scheduleCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.skills.skillCount(a.skillCount)}
>
<GlyphIcon d={CARD_ICONS.skills} size={12} />
{a.skillCount}
</span>
<span
className="inline-flex shrink-0 items-center gap-1"
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
@@ -5,13 +5,14 @@
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
* replace semantics, so toggling also resends every field and only flips `enabled`.
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
* pair; defaults to the Project default; mutual exclusivity with sessionId is
* validated server-side).
* submit; the "new Session each run" mode can also pick a Model — always a complete
* (provider, modelId) pair, since provider is never inferred; omitting it entirely falls
* back to the Project default. Mutual exclusivity with sessionId is validated server-side.
*/
import { useCallback, useEffect, useState } from "react";
import type {
ModelInfo,
ModelRefDto,
ScheduleItem,
SchedulesResponse,
ScheduleStatus,
@@ -49,37 +50,35 @@ function toLocalInput(iso: string | undefined): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* The model selection value in the form: a paired reference. provider may be
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
* unique match at parse time; editing never fills in a provider on its own.
*/
interface ScheduleModelRef {
provider?: string;
modelId: string;
}
/**
* Model reference ↔ native select option value: encoded as a JSON array
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
* ([provider, modelId]), used only as a transient DOM-value serialization —
* it never enters storage or requests; the persisted/submitted data still keeps
* provider and modelId as two separate fields (no concatenation anywhere in the
* pipeline). "" means the Project default.
*/
const modelOptionValue = (ref: ScheduleModelRef): string =>
JSON.stringify([ref.provider ?? null, ref.modelId]);
const modelOptionValue = (ref: ModelRefDto): string => JSON.stringify([ref.provider, ref.modelId]);
const parseModelOption = (v: string): ScheduleModelRef | null => {
const parseModelOption = (v: string): ModelRefDto | null => {
if (!v) return null;
const [provider, modelId] = JSON.parse(v) as [string | null, string];
return provider === null ? { modelId } : { provider, modelId };
const [provider, modelId] = JSON.parse(v) as [string, string];
return { provider, modelId };
};
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
const modelOptionLabel = (ref: ScheduleModelRef): string =>
ref.provider === undefined
? ref.modelId
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
const modelOptionLabel = (ref: ModelRefDto): string =>
`${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
/**
* Stored schedule fields → a model reference. A reference is always the complete
* (provider, model_id) pair, since provider is never inferred: the DTO types the two
* fields independently, so this guard is what keeps the form and the upsert body from
* ever assembling half a reference. A file that sets only one half is rejected by the
* server when parsed (it surfaces under invalidFiles, never as a listed row), so in
* practice this returns null only when the schedule uses the Project's default model.
*/
const itemModelRef = (item: Pick<ScheduleItem, "provider" | "modelId">): ModelRefDto | null =>
item.modelId && item.provider ? { provider: item.provider, modelId: item.modelId } : null;
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
interface FormState {
@@ -94,8 +93,8 @@ interface FormState {
target: "new" | "session";
sessionId: string;
workspace: string;
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
model: ScheduleModelRef | null;
/** Model for the new-Session mode (null = Project default, provider and modelId both omitted). */
model: ModelRefDto | null;
}
const EMPTY_FORM: FormState = {
@@ -191,10 +190,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
? { workspace: form.workspace.trim() }
: {}),
...(form.target === "new" && form.model
? {
modelId: form.model.modelId,
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
}
? { modelId: form.model.modelId, provider: form.model.provider }
: {}),
};
setBusy(true);
@@ -218,6 +214,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
setBusy(true);
setError(null);
setNotice(null);
const model = itemModelRef(item);
try {
await api.updateSchedule(projectId, agentId, item.name, {
prompt: item.prompt,
@@ -227,9 +224,8 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
...(item.provider !== undefined ? { provider: item.provider } : {}),
// Model reference is resent as a whole pair or not at all — never half of one.
...(model ? { modelId: model.modelId, provider: model.provider } : {}),
});
setNotice(S.common.saved);
await load();
@@ -240,7 +236,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
}
};
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
/** Edit: prefill this row into the modal form (submits via PUT; the model reference is prefilled only as a complete pair). */
const startEdit = (item: ScheduleItem) => {
openForm({
editing: item.name,
@@ -253,13 +249,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
target: item.sessionId ? "session" : "new",
sessionId: item.sessionId ?? "",
workspace: item.workspace ?? "",
model:
item.modelId !== undefined
? {
modelId: item.modelId,
...(item.provider !== undefined ? { provider: item.provider } : {}),
}
: null,
model: itemModelRef(item),
});
};
@@ -494,9 +484,9 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
>
<option value="">{S.schedule.modelDefault}</option>
{/* When the prefilled reference is no longer in the model config (including a
hand-written file missing only provider), add an extra option so it isn't
displayed as "Project default". */}
{/* When the prefilled pair is no longer in the model config (the entry was
renamed or deleted), add an extra option so it isn't displayed as
"Project default". */}
{form.model &&
!models.some(
(m) =>